Skip to content

perf(rpc): reduce allocs in receipt with block info and BlockWith{Receipt/Txs} - #3908

Open
EgeCaner wants to merge 10 commits into
mainfrom
perf/rpc/receipt-cont
Open

perf(rpc): reduce allocs in receipt with block info and BlockWith{Receipt/Txs}#3908
EgeCaner wants to merge 10 commits into
mainfrom
perf/rpc/receipt-cont

Conversation

@EgeCaner

@EgeCaner EgeCaner commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.33333% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.60%. Comparing base (98ce435) to head (64afdce).

Files with missing lines Patch % Lines
rpc/v10/transaction.go 72.00% 7 Missing ⚠️
rpc/v9/transaction.go 81.08% 7 Missing ⚠️
rpc/v10/subscription_receipts.go 60.00% 0 Missing and 2 partials ⚠️
rpc/v9/subscription_receipts.go 60.00% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3908      +/-   ##
==========================================
- Coverage   74.76%   74.60%   -0.16%     
==========================================
  Files         461      461              
  Lines       40915    40910       -5     
==========================================
- Hits        30590    30522      -68     
- Misses       8232     8285      +53     
- Partials     2093     2103      +10     
Flag Coverage Δ
jsonv2 77.33% <83.33%> (-0.08%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@EgeCaner
EgeCaner force-pushed the perf/rpc/receipt-cont branch from 42b94aa to 01325a8 Compare August 5, 2026 13:50
@EgeCaner
EgeCaner marked this pull request as ready for review August 5, 2026 13:53
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @EgeCaner's task in 7m 1s —— View job


Review: perf(rpc) — receipt/block alloc reduction

  • Gather context (diff vs origin/main, CLAUDE.md, repo conventions)
  • Review rpc/v10 changes (adapt_transaction, block, transaction_types, subscriptions)
  • Review rpc/v9 changes (transaction, block, subscriptions)
  • Verify JSON wire compatibility of the type split
  • Check for stale consumers of the moved BlockHash/BlockNumber fields
  • Type-check + escape analysis
  • Post review

Summary

The TXN_RECEIPT / TXN_RECEIPT_WITH_BLOCK_INFO split is the right modelling change and matches the spec — block_hash/block_number no longer sit on the plain receipt where they were always nil (BlockWithReceipts), and the pointer-slice → value-slice moves cut real indirection. Wire format looks unchanged: block_number went *uint64,omitemptyuint64, and since omitempty never omitted a pointer-to-zero, genesis receipts serialized "block_number":0 before and after; block_hash is still omitted for pre-confirmed. Field order shifts (block info moves to the end of the object), which is harmless for JSON-RPC clients. I also checked that nothing outside rpc/v9/rpc/v10 read the moved fields — no stale consumers.

No blockers. Details are in 4 inline comments.

Important

  • rpc/v9/transaction.go:1010AdaptTransaction = *AdaptCoreTransaction(t) doesn't remove the per-tx allocation, it adds a copy on top of it. go build -gcflags='-m' ./rpc/v9 confirms &Transaction{...} escapes to heap at lines 1019/1038, and AdaptCoreTransaction is not inlined into the wrapper. For BlockWithTxs the alloc count is unchanged vs main; only the pointer-slice indirection is gone. Same holds in v10 (transaction.go:45, adapt_transaction.go:36,55). Fixing it means flipping the direction: value-returning adapters as the primitive, pointer form as the thin wrapper. Given the PR title, benchmark numbers (benchstat, à la addtxn_bench_test.go) would settle which parts pay off.
  • rpc/v9/block.go:435 — slice sized from header.TransactionCount but indexed by blockTxns; header and txs come from two separate pre-confirmed Head() reads, so a tx arriving in between panics the handler. v10 uses len(blockTxns) in this same commit; v9 should match. Pre-existing, but the value-slice change also turns the under-count case from null entries into bogus empty tx objects, so it's worth closing here.

Nit

  • rpc/v10/transaction_test.go:1121 — dropping require.NotNil(t, expected.BlockNumber) loses the guard that testdata carries block_number; a missing field now silently means block 0. require.NotZero keeps the intent.
  • rpc/v9/transaction.go:1014AdaptCoreTransaction is newly exported in v9 with zero callers (unlike v10).

The remaining test churn is mechanical pointer→value adaptation with no weakened assertions.

Verification: go vet ./rpc/v9/... ./rpc/v10/... passes clean (this type-checks the test files too). go build ./... compiles everything and fails only at the final link step for cmd/juno because the Rust static libs (libjuno_starknet_rs, libjuno_starknet_compiler_rs) aren't built in this environment — unrelated to the diff. I could not run go test (command not in the allowed tool list), so the assertions above about test behaviour are from reading, not execution.
· branch perf/rpc/receipt-cont

Comment thread rpc/v9/transaction.go Outdated
Comment thread rpc/v9/block.go Outdated
Comment thread rpc/v10/transaction_test.go
Comment thread rpc/v9/transaction.go Outdated
@EgeCaner
EgeCaner force-pushed the perf/rpc/receipt-cont branch from 25480ae to 2aa57e9 Compare August 5, 2026 14:40
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @EgeCaner's task in 5m 14s —— View job


Re-review after push (2aa57e9)

  • Diff the new commits vs the state I reviewed earlier
  • Check which of the 4 earlier comments are addressed
  • Review the new changes for correctness/perf regressions
  • Type-check + escape analysis
  • Post updated review

Status of my earlier comments

Finding Status
rpc/v9/block.go sized by header.TransactionCount, indexed by blockTxns → panic on pre_confirmed Fixed in 2aa57e9 — now len(blockTxns), matching v10
require.NotNil(t, expected.BlockNumber)require.NotZero My mistake, withdrawn — you're right. receipt_accepted_on_l1.json, receipt_accepted_on_l2.json, receipt_deploy.json and receipt_pre_confirmed.json all legitimately carry "block_number": 0, so NotZero would fail. No change needed.
AdaptCoreTransaction exported in v9 with zero callers 🟡 Still true, but subsumed by the finding below — applying it gives the function four callers
AdaptTransaction = *AdaptCoreTransaction(t) doesn't remove the per-tx alloc 🟡 Unchanged; see below

New finding

Important

  • rpc/v9/transaction.go:548 — the four single-transaction handlers changed from return AdaptTransaction(txn), nil (1 alloc) to adaptedTxn := AdaptTransaction(txn); return &adaptedTxn (2 allocs + a struct copy), because AdaptCoreTransaction already heap-allocates internally and taking the address of the returned copy allocates again. -gcflags='-m' shows moved to heap: adaptedTxn at lines 548, 560, 590, 620 on top of &Transaction{...} escapes to heap at 1019/1038. Calling AdaptCoreTransaction(txn) directly at those four sites restores the single alloc and resolves the "exported with no callers" nit at the same time. Small in absolute terms (one extra alloc per getTransactionByHash call, not per tx in a block), but a regression in a perf PR with a one-line fix. Fix this →

Nit / follow-up (out of scope, pre-existing)

  • The same header-vs-txs mismatch you just fixed still exists in BlockWithTxHashes: rpc/v9/block.go:351 and rpc/v10/block.go:195 both do make([]*felt.Felt, header.TransactionCount) and then index by blockTxns. I re-confirmed the two values come from independent pre-confirmed snapshots (blockHeaderByIDPreConfirmedChain() at rpc/v9/helpers.go:119, blockTxnsByNumber → a second PreConfirmedChain() at :93), so a tx landing between the two reads panics that handler on a public RPC. Not introduced here and fine to leave for a separate PR — just noting the fix is the identical one-liner.

Everything else

The TXN_RECEIPT / TXN_RECEIPT_WITH_BLOCK_INFO split still looks right and matches the spec, and the receipt-side value returns are clean: AdaptReceipt returning by value feeds directly into the TransactionWithReceipt value field with no intermediate alloc, and AdaptReceiptWithBlockInfo is a single composite-literal alloc. Wire format unchanged (block_hash still omitted for pre-confirmed; block_number was *uint64,omitempty which never omitted pointer-to-zero, so genesis receipts serialize "block_number":0 as before). I also re-checked pre-confirmed snapshot consistency for the paths that read Block.TransactionCount and Block.Transactions from a single chain.Head() (v9/transaction.go:586, v10/block.go:254) — those are safe, since chain_storage.go replaces the slot wholesale rather than mutating in place.

The remaining BlockWithTxs/BlockWithReceipts alloc story is unchanged from my last pass: the value slices remove pointer indirection and one level of chasing during marshalling, but the per-tx Transaction alloc is still there inside AdaptCoreTransaction, so the alloc count for those endpoints matches main. Still think benchstat numbers (precedent: rpc/v10/addtxn_bench_test.go) would be the cleanest way to show which parts of this pay off — no objection to merging without them.

Verification: go vet ./rpc/v9/... ./rpc/v10/... clean (type-checks tests too). Escape analysis via go build -gcflags='-m' on both packages. I could not run go test (not in the allowed tool list), so statements about test behaviour come from reading, not execution.
· branch perf/rpc/receipt-cont

Comment thread rpc/v9/transaction.go Outdated

@brbrr brbrr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Please check my comments and the unresolved comments from the bot.

Also, can you provide a benchmark for this PR?

Comment thread rpc/v10/block.go Outdated
Comment thread rpc/v9/transaction.go Outdated
Comment thread rpc/v10/subscription_receipts.go Outdated
@EgeCaner
EgeCaner force-pushed the perf/rpc/receipt-cont branch from 2aa57e9 to 4e760d1 Compare August 14, 2026 15:25
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @EgeCaner's task in 5m 35s —— View job


Re-review after force-push (4e760d1)

  • Diff new commits vs the previously reviewed state
  • Check status of open review comments (mine + @brbrr's)
  • Review new changes for correctness / perf regressions
  • Type-check + escape analysis
  • Post updated review

Status of open comments

Finding Status
AdaptTransaction = *AdaptCoreTransaction(t) doesn't remove the per-tx alloc — value adapter should be the primitive FixedAdaptCoreTransaction / adaptInvoke / adaptDeclare / adaptDeployAccount now return Transaction by value in both v9 and v10
adaptedTxn := AdaptTransaction(txn); return &adaptedTxn was a 2-alloc path (@brbrr: "worth addressing") Fixed by the flip above — now exactly one alloc per call, no memcpy of an already-heap struct
@brbrr: iter.Seq[TransactionReceiptWithBlockInfo] instead of the pointer seq Done in v9 + v10, incl. sendTransactionReceipt and TransactionReceiptByHash / getPendingTransactionReceipt returning by value
@brbrr: stale // block_hash, block_number are optional… comment in rpc/v10/block.go Removed
rpc/v9/block.go sized by header.TransactionCount ✅ Fixed earlier (len(blockTxns))
require.NotZero(t, expected.BlockNumber) ✅ Withdrawn — fixtures legitimately carry "block_number": 0
AdaptCoreTransaction exported in v9 with ~no callers 🟡 Still open, see nit below
@brbrr: benchmark numbers for the PR 🔴 Not posted yet

What the escape analysis now shows

The alloc reduction is real this time. go build -gcflags='-m' ./rpc/v9 ./rpc/v10 no longer reports any &Transaction{...} escapes to heap inside the adapters — the composite literals stay on the stack and are written straight into txs[index] / txsWithReceipts[index]. So BlockWithTxs/BlockWithReceipts go from N heap Transactions + 1 pointer slice to 1 value slice, which is the change the PR title claims. The four single-tx handlers still show moved to heap: adaptedTxn (v9 transaction.go:548,560,590,620), but that's now the only allocation on those paths — matching main's single alloc, no extra copy. Receipts are the same story: AdaptReceiptWithBlockInfo returns a value that lands directly in the caller's slot, and the subscription iterator no longer allocates per yielded receipt.

Wire compatibility re-checked on the new shape: neither TransactionReceipt nor TransactionReceiptWithBlockInfo (nor Transaction) declares MarshalJSON/UnmarshalJSON, so moving from pointer to value returns can't silently drop a pointer-receiver marshaller — encoding is identical. jsonrpc.Server only reads tuple[0] when the *jsonrpc.Error is nil (jsonrpc/server.go:553-566), so the return TransactionReceiptWithBlockInfo{}, err paths never serialize the zero struct, and registerMethod places no constraint on the result type. Embedding flattens as expected (block_hash still omitempty, block_number always emitted — same as the old non-nil *uint64). No consumers of the moved fields or the new type exist outside rpc/v9/rpc/v10.

Findings

Blocker / Important: none.

Nit

  • rpc/v9/transaction.go:1005-1008AdaptTransaction and AdaptCoreTransaction are now byte-for-byte equivalent exported functions in v9, and AdaptCoreTransaction's only caller is the wrapper. Worth collapsing to one name (v10 keeps both legitimately because of includeProofFacts).

Follow-up (pre-existing, untouched here)

  • rpc/v9/block.go:351 and rpc/v10/block.go:195 still do make([]*felt.Felt, header.TransactionCount) and index by blockTxns in BlockWithTxHashes — the header and the txs come from two independent PreConfirmedChain() reads, so a tx landing between them panics that handler. Identical one-liner to the fix already applied to BlockWithTxs; fine as a separate PR.

Housekeeping: 4e760d1 "leftover" is a one-line test change — worth squashing into 1c52093 before merge.

Verification: go vet ./rpc/v9/... ./rpc/v10/... clean (this type-checks the test files too), plus go build -gcflags='-m' on both packages for the escape claims above. I could not run go test or go test -bench (not in the allowed tool list), so statements about test behaviour come from reading, not execution — which also means I can't produce the benchstat numbers @brbrr asked for; that one needs to come from you.
· branch perf/rpc/receipt-cont

@EgeCaner
EgeCaner deployed to Development August 14, 2026 15:35 — with GitHub Actions Active
Comment thread rpc/v9/transaction.go Outdated
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @EgeCaner's task in 7m 18s —— View job


Re-review after push (0a6aac0)

  • Diff the new commit vs the previously reviewed state (4e760d1)
  • Check status of open review comments
  • Review new changes for correctness / perf regressions
  • Type-check + escape analysis
  • Post updated review

No blockers, no important findings — this round is clean. Both items still open from my last pass are fixed, and I found no new issues.

Status of previously open comments

Finding Status
4 single-tx handlers did adaptedTxn := AdaptTransaction(...); return &adaptedTxn (2 allocs) FixedTransactionByHash / TransactionByBlockIDAndIndex now return Transaction by value in both v9 and v10
v9 AdaptTransaction and AdaptCoreTransaction were duplicate exported functions FixedAdaptCoreTransaction dropped from v9 (v10 correctly keeps both; AdaptTransaction there carries includeProofFacts)
@brbrr: benchmark numbers for the PR 🔴 Still not posted — see below
header.TransactionCount sizing in BlockWithTxHashes 🟡 Pre-existing, out of scope (still at rpc/v9/block.go:351, rpc/v10/block.go:195)

What I verified on the new value-return shape

The switch from *Transaction to Transaction at a JSON-RPC handler boundary has two non-obvious failure modes; both are clear here.

Zero struct never serializes. jsonrpc/server.go:564-566 sets res.Result = tuple[0].Interface() only after the *jsonrpc.Error return is confirmed nil, so the return Transaction{}, rpccore.ErrTxnHashNotFound paths can't leak an empty object where null used to go. registerMethod puts no constraint on the result type.

Addressability of the marshalled value. This is the real hazard: a struct handed to json.Marshal through an any is not addressable, so a pointer-receiver-only MarshalJSON on the type — or on any value field of it — would silently be skipped now, where the old *Transaction return kept everything addressable. Checked all of it:

  • Neither Transaction, TransactionReceipt, nor TransactionReceiptWithBlockInfo declares MarshalJSON/MarshalText in either version.
  • The only pointer-receiver MarshalJSON reachable from these types is *ResourceBoundsMap (rpc/v9/transaction.go:259, rpc/v10/transaction_types.go:243), and it's referenced as ResourceBounds *ResourceBoundsMap — a pointer field, so the marshaller is still reached regardless of addressability. Every other marshaller in the two packages (Sync, ExecuteInvocation, SimulateTransactionsResponse, LimitSlice) has a value receiver and isn't a field of these types.

So wire output is byte-identical. Embedding still flattens as before (block_hash omitempty, block_number always emitted — matching the old always-non-nil *uint64), and no consumer of the moved fields or the new type exists outside rpc/v9/rpc/v10 (only node/node.go imports these packages, and just for Validator()).

Allocation accounting. go build -gcflags='-m' ./rpc/v9 ./rpc/v10 now reports zero &Transaction{...} escapes to heap and zero moved to heap: adaptedTxn across both packages — the composite literals stay on the stack and are written straight into the caller's slot or into txs[index]. Worth noting the alloc that disappeared is the handler's own: reflect.Value.call heap-allocates the return frame for any handler with return values, so the boundary alloc exists in both shapes and merely grows by sizeof(Transaction); Value.Interface() then reuses that frame pointer rather than copying. Net for these four endpoints: one fewer allocation than main, and no memcpy of an already-heap struct.

Test churn is mechanical: assert.Nilassert.Empty on the error paths is the exact value-type equivalent (testify's isEmpty compares against reflect.Zero), and require.Equal(t, &expected, got)require.Equal(t, expected, got) preserves the assertion. Nothing weakened.

Remaining asks (unchanged, both yours to close)

  • Benchmarks. @brbrr's request is still outstanding, and it's the one thing I can't produce — go test -bench isn't in my allowed tool list. BlockWithTxs / BlockWithReceipts are where the story is strongest now (N heap Transactions + pointer slice → one value slice); precedent for the harness is rpc/v10/addtxn_bench_test.go, and benchstat against origin/main would make the title concrete.
  • Housekeeping. 4e760d1 "leftover" is still a standalone one-line test change — worth squashing into 1c52093 before merge.

Verification: go vet ./rpc/... clean (all versions, tests type-checked), plus go build -gcflags='-m' on rpc/v9 and rpc/v10 for the escape claims. I could not run go test (not in the allowed tool list), so statements about test behaviour come from reading, not execution.
· branch perf/rpc/receipt-cont

@EgeCaner
EgeCaner deployed to Development August 14, 2026 16:43 — with GitHub Actions Active
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @EgeCaner's task in 3m 54s —— View job


Re-review after push (526e78e)

  • Diff the new commit vs the previously reviewed state (0a6aac0)
  • Check status of open review comments
  • Review the new changes for correctness / perf regressions
  • Type-check + escape analysis
  • Post updated review

No blockers, no important findings. The new commit is a 10-line change and it does what its message says.

What changed in 526e78e

sendTransactionReceipt now takes *TransactionReceiptWithBlockInfo instead of the value, with the two call sites passing &receipt from the receiptsOf range loop (and the test helper switching to &receipts[i]).

The claim checks out. sendResponse funnels the receipt into a result any field, and boxing a 168-byte struct into an interface calls mallocgc + copies. So:

allocs 168-byte copies
value (0a6aac0) 1 (interface boxing) 2 — yield → loop var, loop var → heap box
pointer (526e78e) 1 (loop var itself) 1 — yield → loop var

go build -gcflags='-m' confirms the alloc just moved rather than doubling: moved to heap: receipt at rpc/v9/subscription_receipts.go:109,127 and rpc/v10/subscription_receipts.go:110,128, with no additional escape on the sendResponse path. Net: same alloc count, one memcpy per yielded receipt removed. iter.Seq[TransactionReceiptWithBlockInfo] is untouched, so @brbrr's request still stands as implemented — only the send helper's parameter flipped.

No aliasing hazard from &receipt. Two things had to hold and both do: the range variable is per-iteration under Go 1.22+ loopvar semantics (each &receipt is a distinct object, so a partially-overwritten receipt can't be observed), and sendResponse marshals synchronously and only hands wsConn.Write the resulting []byte — the pointer isn't retained past the call, so nothing can observe the receipt after the loop moves on.

Wire format still byte-identical. This is the one way a pointer↔value flip can silently change output: a pointer-receiver MarshalJSON is reachable through a pointer but skipped for a non-addressable value. Neither TransactionReceiptWithBlockInfo nor the embedded TransactionReceipt declares MarshalJSON/UnmarshalJSON/MarshalText in either version, and the only pointer-receiver marshaller reachable from the tree (*ResourceBoundsMap) is already held through a pointer field, so it was reached in both shapes. json.Marshal transparently dereferences non-nil pointers, so the emitted object is unchanged.

The test-helper change (for _, receiptfor i := range + &receipts[i]) is assertion-neutral: assertNextMessage marshals the expected value and string-compares against the wire bytes, which produces the same JSON either way.

Full-PR state

Everything from my previous passes still holds and nothing regressed: the TXN_RECEIPT / TXN_RECEIPT_WITH_BLOCK_INFO split matches the spec (block_hash omitempty, block_number always emitted — same as the old always-non-nil *uint64), go build -gcflags='-m' ./rpc/v9 ./rpc/v10 reports zero &Transaction{...} escapes to heap and zero moved to heap: adaptedTxn, the header.TransactionCountlen(blockTxns) fix is in place at rpc/v9/block.go:435, and no consumer of the moved fields exists outside rpc/v9/rpc/v10.

Remaining asks (both yours to close, unchanged)

  • Benchmarks. @brbrr's request from Aug 7 is still open. BlockWithTxs / BlockWithReceipts are the strongest story (N heap Transactions + pointer slice → one value slice); harness precedent is rpc/v10/addtxn_bench_test.go, and benchstat vs origin/main would make the title concrete. go test -bench isn't in my allowed tool list, so I can't produce these.
  • Housekeeping. 4e760d1 "leftover" is still a standalone one-line test change — worth squashing into 1c52093 before merge.

Follow-up (pre-existing, out of scope): rpc/v9/block.go:351 and rpc/v10/block.go:195 still size by header.TransactionCount and index by blockTxns in BlockWithTxHashes; the two come from independent PreConfirmedChain() reads, so a tx landing between them panics that handler. Identical one-liner to the fix already applied here — fine as a separate PR.

Verification: go vet ./rpc/v9/... ./rpc/v10/... clean (this type-checks the test files too), plus go build -gcflags='-m' on both packages for every escape/alloc claim above. I could not run go test (not in the allowed tool list), so statements about test behaviour come from reading, not execution.
· branch perf/rpc/receipt-cont

@EgeCaner
EgeCaner deployed to Development August 14, 2026 17:00 — with GitHub Actions Active
@EgeCaner
EgeCaner force-pushed the perf/rpc/receipt-cont branch from 526e78e to 0d165af Compare August 14, 2026 17:14
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @EgeCaner's task in 7m 18s —— View job


Re-review after rebase + push (0d165af)

  • Diff the new state vs the previously reviewed commit (526e78e)
  • Check status of open review comments
  • Review new changes for correctness / perf regressions
  • Type-check + escape analysis
  • Post updated review

No blockers, no important findings. The rebase brought adaptTransactions in from main and the new commit converts it to a value slice in both versions — that's the whole delta, and it's consistent with the rest of the PR.

Status of previously open comments

Finding Status
adaptTransactions returning []*Transaction with &adaptedTransaction per element Fixed in 0d165af[]Transaction written in place, both v9 and v10
4 single-tx handlers did adaptedTxn := ...; return &adaptedTxn (2 allocs) ✅ Fixed — value returns
v9 AdaptTransaction / AdaptCoreTransaction duplicate exported functions ✅ Fixed — AdaptCoreTransaction dropped from v9; v10 keeps both (includeProofFacts)
header.TransactionCount sizing in BlockWithTxHashes (my long-running follow-up) Mootmain refactored both handlers to transactionHashesOf(...) / TransactionHashesByBlockNumber; grep TransactionCount shows zero remaining make(..., header.TransactionCount) sites in either version
@brbrr: benchmark numbers 🔴 Still not posted

What I verified on the new state

Allocation accounting. go build -gcflags='-m' ./rpc/v9 ./rpc/v10 reports zero &Transaction{...} escapes to heap and zero moved to heap: adaptedTxn across both packages. BlockWithTxs and BlockWithReceipts are now one contiguous make([]Transaction, n) / make([]TransactionWithReceipt, n) with the composite literals written straight into adapted[index] — down from N heap Transactions + 1 pointer slice. subscription_transactions.go also picks up a free win: Transaction: *AdaptTransaction(txn)AdaptTransaction(txn) drops both an alloc and a deref-copy at :169/:191.

Value slices removed a latent nil-deref, not just indirection. AdaptFeederBlockTrace reads block.Transactions[index].Type (rpc/v9/adapters.go:175, rpc/v10/adapt_trace.go:323). With []*Transaction a nil tail entry — which the old header.TransactionCount sizing could produce — would panic there; []Transaction can't. Both call sites are read-only, so no aliasing semantics changed. TransactionWithReceipt.Receipt going from pointer to value has no in-place mutators either: AdaptReceipt has no callers outside rpc/v9/rpc/v10 (rpc/v8 keeps its own 5-arg signature, untouched), and BlockWithReceipts is the only consumer.

Wire format still byte-identical. The one way a pointer→value flip silently changes output is a pointer-receiver MarshalJSON being skipped on a non-addressable value. Neither Transaction, TransactionReceipt, nor TransactionReceiptWithBlockInfo declares MarshalJSON/UnmarshalJSON/MarshalText in either version, and the only pointer-receiver marshaller in the tree (*ResourceBoundsMap) is held through a pointer field, so it's reached either way. Embedding flattens as before (block_hash omitempty, block_number always emitted — matching the old always-non-nil *uint64). jsonrpc/server.go:565 sets res.Result = tuple[0].Interface() only after the *jsonrpc.Error is confirmed nil, so the return Transaction{}, err / return TransactionReceiptWithBlockInfo{}, err paths can't leak an empty object where null used to go. The TransactionReceiptTransactionReceiptWithBlockInfo split also removes any embedded-field ambiguity, which matters under the GOEXPERIMENT=jsonv2 test job since json/v2 rejects ambiguous inlined members.

Test churn is mechanical. assert.Nilassert.Empty is the exact value-type equivalent (testify's isEmpty falls back to reflect.DeepEqual against reflect.Zero), require.Equal(t, &expected, got)require.Equal(t, expected, got) preserves the assertion, and for _, receiptfor i := range + &receipts[i] in assertNextReceipts is assertion-neutral (assertNextMessage marshals and string-compares the wire bytes). Dropping require.NotNil(t, expected.BlockNumber) remains correct — the fixtures legitimately carry "block_number": 0.

Findings

Nit

  • rpc/v9/subscription_receipts.go:110&receipt on the range variable heap-allocates the loop variable once per yielded receipt (moved to heap: receipt at v9 :109/:127, v10 :110/:128). Against main this path trades 2 allocs + 0 copies for 1 alloc + ~2 × 168-byte copies, so the commit message's "avoid 168-byte copies" is the delta vs 0a6aac0, not vs main. Hoisting the variable above the loop would make it one allocation per block — safe, since sendResponse marshals synchronously and doesn't retain the pointer. Second-order either way: sendResponse allocates a map[string]any plus a marshal buffer per message, which dwarfs the struct copy.

Housekeeping

  • Two commits still want squashing before merge: 8869d65 "leftover" (one-line test change) and 0d165af "rebase fix" (folds into the adaptTransactions change).

Remaining ask

Benchmarks@brbrr's request from Aug 7 is still open, and this is the round where the story is cleanest to tell: BlockWithTxs / BlockWithReceipts go from N heap structs + a pointer slice to a single value slice, which escape analysis now confirms. Harness precedent is rpc/v10/addtxn_bench_test.go; benchstat vs origin/main would make the title concrete. go test -bench isn't in my allowed tool list, so this one has to come from you.

Verification: go vet ./rpc/... clean across all versions (this type-checks the test files too), plus go build -gcflags='-m' on rpc/v9 and rpc/v10 for every escape/alloc claim above. go test isn't in my allowed tool list, so statements about test behaviour come from reading, not execution — CI's golangci-lint and CodeQL are green on 0d165af; Juno Test was still in progress when I looked.
· branch perf/rpc/receipt-cont

Comment thread rpc/v9/subscription_receipts.go
@EgeCaner

Copy link
Copy Markdown
Contributor Author

k6 end-to-end (100k sequential iterations, 1 VU, warm node)

Corpus: 10k uniform blocks (corpus-gen --count 10000 --seed 1), http_req_duration in ms,
0 failed requests in all runs.

method stat before after Δ
getBlockWithTxs med 0.990 0.976 −1.4%
getBlockWithTxs avg 1.378 1.363 −1.1%
getBlockWithTxs p99 8.713 8.728 ~
getBlockWithTxs req/s 509.4 512.6 +0.6%
getBlockWithReceipts med 1.462 1.450 −0.8%
getBlockWithReceipts avg 2.254 2.233 −0.9%
getBlockWithReceipts p99 13.429 13.329 −0.7%
getBlockWithReceipts req/s 274.1 275.7 +0.6%

Cold-start runs agreed directionally (up to −5.4% median on getBlockWithTxs). End-to-end the
change is neutral-to-slightly-positive

@EgeCaner

Copy link
Copy Markdown
Contributor Author

Micro Benchmark

BlockWithTxs

path block (txs) ns/op before ns/op after Δ time allocs before allocs after Δ allocs
committed 0 (18) 40,820 38,930 −4.6% 286 250 −12.6%
committed 763497 (17) 201,200 196,900 −2.2% 351 317 −9.7%
committed 16697 (180) 590,100 575,600 −2.5% 2,738 2,378 −13.2%
committed 19199 (324) 985,100 961,800 −2.4% 4,898 4,250 −13.2%
pre_confirmed 0 (18) 2,046 1,291 −36.9% 52 16 −69.2%
pre_confirmed 763497 (17) 2,580 1,824 −29.3% 73 39 −46.6%
pre_confirmed 16697 (180) 20,860 13,320 −36.2% 534 174 −67.4%
pre_confirmed 19199 (324) 37,170 24,130 −35.1% 964 316 −67.2%
marshal 0 (18) 61,060 60,130 −1.5% 450 414 −8.0%
marshal 763497 (17) 417,000 415,500 ~ 2,622 2,588 −1.3%
marshal 16697 (180) 993,300 979,700 −1.4% 6,919 6,559 −5.2%
marshal 19199 (324) 1,631,000 1,613,000 ~ 11,550 10,900 −5.6%

BlockWithReceipts

path block (txs) ns/op before ns/op after Δ time allocs before allocs after Δ allocs
committed 0 (18) 77,620 75,960 −2.1% 440 386 −12.3%
committed 763497 (17) 605,700 609,400 ~ 2,563 2,512 −2.0%
committed 16697 (180) 1,566,000 1,565,000 ~ 8,927 8,387 −6.1%
committed 19199 (324) 2,579,000 2,577,000 ~ 15,280 14,310 −6.4%
pre_confirmed 0 (18) 3,432 2,732 −20.4% 72 18 −75.0%
pre_confirmed 763497 (17) 4,623 3,907 −15.5% 93 42 −54.8%
pre_confirmed 16697 (180) 42,300 33,970 −19.7% 751 211 −71.9%
pre_confirmed 19199 (324) 71,200 55,400 −22.2% 1,333 361 −72.9%
marshal 0 (18) 109,800 111,100 ~ 711 657 −7.6%
marshal 763497 (17) 1,147,000 1,157,000 ~ 8,313 8,262 −0.6%
marshal 16697 (180) 2,618,000 2,689,000 +2.7% 18,950 18,410 −2.9%
marshal 19199 (324) 4,182,000 4,263,000 +2.0% 30,760 29,790 −3.2%

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @EgeCaner's task in 4m 29s —— View job


Re-review after push (64afdce)

  • Diff the new commit vs the previously reviewed state (0d165af)
  • Review the new typed subscription response struct for correctness / wire compatibility
  • Check status of open review comments
  • Type-check + escape analysis
  • Post updated review

This round is clean — no blockers, no important findings, no inline comments. The delta is 5 files, all one change: SubscriptionResponse goes from Params any + a map[string]any to a generic SubscriptionResponse[T] / SubscriptionParams[T], with sendResponse[T any] inferring T at each call site.

Wire compatibility — the one thing that had to hold

Replacing a map with a struct changes key ordering from "sorted by encoding/json" to "declaration order". The new declaration is Result then SubscriptionIDresult, subscription_id, which is exactly what alphabetical map ordering produced. So output is byte-identical, and the hardcoded fixtures confirm it rather than the test helper merely agreeing with itself: rpc/v9/subscriptions_test.go:1074, :1309 and :2226 all spell out "params":{"result":{…},"subscription_id":"…"} and are untouched by this commit. Getting the two fields in the other order would have broken those and nothing else — worth noting for whoever edits this struct next.

Everything else checks out: SubscriptionID is type SubscriptionID string (rpc/v9/subscriptions.go:76, rpc/v10/subscriptions.go:19), so the test helper's string(id) conversion is a no-op for JSON; T is inferred as a concrete type at all 12 production call sites (never any), and for pointer instantiations json.Marshal still dereferences and still reaches *ResourceBoundsMap's pointer-receiver marshaller through the pointer field, so no marshaller gets skipped; a nil *ReorgEvent/*TransactionReceiptWithBlockInfo still emits null exactly as the boxed-interface form did. The generic types are only referenced inside rpc/v9/rpc/v10 and their tests, so making the exported name generic breaks no consumer — rpc/v8 keeps its own non-generic copy (subscriptions.go:31), untouched.

Allocation accounting

go build -gcflags='-m' ./rpc/v9 ./rpc/v10 now shows a single escape per send — SubscriptionResponse[go.shape.*uint8]{...} escapes to heap at rpc/v9/subscriptions.go:355 / rpc/v10/subscriptions.go:239 (the boxing for json.Marshal's any parameter, unavoidable) — where main paid that same box plus the map header/bucket, plus boxing id into any, plus boxing result into any whenever it was a value type (SubscriptionTransactionStatus, SubscriptionNewTransaction). Roughly 4–5 allocs per subscription message → 1. Secondary win the diff doesn't advertise: struct marshalling uses a cached per-type encoder, while map[string]any re-sorts keys and goes through reflect per entry on every message.

sendResponse also became inlinable at every instantiation (can inline sendResponse[...] × 6 in v10), which it wasn't before.

This is the change that makes my standing nit about &receipt in the receipt loops moot in practice — I'd argued the per-iteration loop-var escape was second-order because sendResponse allocated a map plus a marshal buffer per message. That map is now gone, so the remaining moved to heap: receipt (v9 subscription_receipts.go:109,:127; v10 :110,:128) is a larger share of a much smaller total. Still not worth another commit, and hoisting it would be safe (sendResponse marshals synchronously and doesn't retain the pointer) — noting it only so the reasoning stays consistent.

Status of the PR's open threads

Item Status
@brbrr: benchmark numbers Closed — k6 end-to-end + per-block micro numbers posted above
iter.Seq[TransactionReceiptWithBlockInfo], stale v10 comment, AdaptCoreTransaction duplication, 2-alloc handler returns, header.TransactionCount sizing ✅ All closed in earlier rounds
&receipt loop-var escape 🟡 Nit, declined — fine

On the benchmarks: the pre_confirmed column is the strongest evidence (−67% to −75% allocs, −20% to −37% time) and matches what escape analysis predicts, since that path has no DB/trie work to hide behind. The marshal rows showing +2.0%/+2.7% on BlockWithReceipts with fewer allocs are the one oddity — most likely a value slice of large TransactionWithReceipt structs costing more in reflect-driven traversal than the pointer chase it replaced, and plausibly within run-to-run noise at 3 significant figures. Not worth blocking on given the end-to-end numbers are neutral-to-positive, but if it reproduces under -count=10 | benchstat it'd be the one place this PR trades time for allocs. Those numbers predate this commit and are unaffected by it — 64afdce touches only the subscription path.

Housekeeping (unchanged)

Two commits still want squashing before merge: 8869d65 "leftover" (one-line test change) and 0d165af "rebase fix" (folds into the adaptTransactions change).

Follow-up, out of scope

rpc/v8/subscriptions.go:665 still has the old map[string]any sendResponse. Leaving v8 alone is the right call for a perf PR — just flagging that the three versions now differ in shape, and that v9 declares the generic types in subscriptions.go while v10 puts them in subscription_types.go.

Verification: go vet ./rpc/v9/... ./rpc/v10/... clean (this type-checks the test files too), plus go build -gcflags='-m' on both packages for every escape/alloc claim above. I could not run go test or go test -bench (not in my allowed tool list), so statements about test behaviour come from reading, not execution — the benchmark commentary is analysis of your posted numbers, not a re-run. CI on 64afdce: golangci-lint, CodeQL, Code Quality and Dependency Review green; Juno Test still in progress when I looked.
· branch perf/rpc/receipt-cont

Comment on lines +10 to +18
type SubscriptionParams[T any] struct {
Result T `json:"result"`
SubscriptionID string `json:"subscription_id"`
}

type SubscriptionResponse[T any] struct {
Version string `json:"jsonrpc"`
Method string `json:"method"`
Params SubscriptionParams[T] `json:"params"`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saved per notification: the map[string]any header and its bucket (2), the heap box for the id string stored as any (1), and encoding/json's per-call map machinery — the reflected key collection, the sortable key/value slice, and per-key string resolution (~5) — a constant 8 allocations per message regardless of payload size (9 for value payloads like transactionStatus, which also skip one any box).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type payload sec/op allocs/op
newHeads full adapted header 1.62µs → 1.26µs 25 → 17
events 1 event (1 key, 1 data felt) 975ns → 635ns 16 → 8
newTransaction invoke v3, 2 calldata + 1 sig 2.54µs → 2.16µs 27 → 19
newTransactionReceipts receipt with 1 event 1.32µs → 978ns 20 → 12
transactionStatus hash + finality/execution status 703ns → 355ns 14 → 5
reorg 2 hashes + 2 block numbers 649ns → 320ns 12 → 4

@EgeCaner
EgeCaner deployed to Development August 14, 2026 19:13 — with GitHub Actions Active

@brbrr brbrr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. I've left a single observation that could potentially remove another allocation. Leaving it up to you to decide if it's worth doing.

func sendTransactionReceipt(wsConn jsonrpc.Conn, receipt *TransactionReceipt, id string) error {
func sendTransactionReceipt(
wsConn jsonrpc.Conn,
receipt *TransactionReceiptWithBlockInfo,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pass the receipt by value here? It should prevent receipt to escape to heap. Same call in v9

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants