Skip to content

refactor(rpc): replace subscription cache with pre_confirmed tip deduper#3778

Open
EgeCaner wants to merge 13 commits into
feat/integ-multi-preconfirmedfrom
refactor/subscription-preconfirmed-deduper
Open

refactor(rpc): replace subscription cache with pre_confirmed tip deduper#3778
EgeCaner wants to merge 13 commits into
feat/integ-multi-preconfirmedfrom
refactor/subscription-preconfirmed-deduper

Conversation

@EgeCaner

@EgeCaner EgeCaner commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Replace the subscription cache with a per-subscription PreConfirmedDeduper. Because new heads (canonical blocks) arrive exactly once, only the pre_confirmed tip can produce duplicates (it's re-published in full on every delta), so we only need to dedup within the current tip round. During historical handoff, event subscriptions are bounded to canonical blocks, any pre_confirmed blocks missed in the gap will eventually arrive as canonical, so no cross-round caching is needed.

@EgeCaner EgeCaner requested review from rodrodros and thiagodeev July 1, 2026 16:06
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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


Review: refactor(rpc): replace subscription cache with pre_confirmed tip deduper

  • Read the new PreConfirmedDeduper and its test
  • Review v10 subscription changes (events / receipts / transactions)
  • Review v9 subscription changes
  • Check deduper wiring, concurrency, and edge cases
  • Post findings + summary

Verdict: clean. This is a solid simplification — replacing the 3-slot value-caching SubscriptionCache with a single-round key-only PreConfirmedDeduper is well justified and the code holds up. No blockers found.

Why the design is sound

I verified the two assumptions the refactor rests on:

  1. The pre_confirmed feed is monotonic. onPreConfirmed is fed from a single feed.Feed[*pending.PreConfirmed] via SubscribeKeepLast (rpc/v10/subscriptions.go:67, rpc/v9 equivalent). The tip only ever advances forward, so two pre_confirmed heights never interleave — a single-round deduper cannot thrash. This is what makes dropping the old "up to 2 pre_confirmed blocks" capacity safe.
  2. BlockIdentifier is stable within a round and changes on replacement. AdaptPreConfirmedWithDelta rejects deltas whose BlockIdentifier differs (adapters/sn2core/sn2core.go:589), and the feeder rejects an empty identifier (starknet/pre_confirmed_update.go:38,69). So keying the deduper reset on (block.Number, BlockIdentifier) correctly distinguishes a same-height round replacement from a delta.

Other checks

  • Concurrency: the deduper is per-subscription and touched only from the single dispatch goroutine (onStart/onReorg/onNewHead/onPreConfirmed all run in the same select loop). The documented lock-free / not-safe-for-concurrent-use contract matches actual usage. ✅
  • No dedup regression: the old cache stored finality status as the value and only re-sent on status change; within a pre_confirmed round the status is constant (TxnPreConfirmed), so key-only dedup is behaviourally equivalent. Dropping TransactionIndex from the receipt key is fine since tx hashes are unique within a block. ✅
  • Handoff gap: historical replay is bounded to the canonical tip and onNewHead stays registered for event subscribers, so blocks in the handoff window still arrive as canonical — no lost notifications. ✅
  • Tests: preconfirmed_deduper_test.go covers same-round dedup, block advance, same-height identifier change, Clear, and block-zero; the subscription tests were updated to exercise the feed-driven handoff. Good coverage.

Minor observations (non-blocking, no action required)

  • MarkSent(..., key *K) dereferences *key; the receipt/transaction call sites pass (*felt.TransactionHash)(receipt.Hash) / (txn.Hash), so a nil hash would panic. This isn't a regression (the previous code dereferenced the same hashes) and pre_confirmed txns always carry a hash, so it's only a theoretical concern.
  • The v10 onNewHead comment (subscription_events.go:143) is slightly terser than v9's, which also notes the acceptable one-time boundary-block duplicate — purely cosmetic.

⚠️ I could not run the test suite (go test is blocked by the CI sandbox permissions here) — go build ./rpc/... passed cleanly. Please confirm CI green before merge.
· refactor/subscription-preconfirmed-deduper

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.66102% with 48 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (feat/integ-multi-preconfirmed@fdfff54). Learn more about missing BASE report.

Files with missing lines Patch % Lines
rpc/v10/subscription_events.go 74.00% 8 Missing and 5 partials ⚠️
rpc/v9/subscription_events.go 74.00% 8 Missing and 5 partials ⚠️
rpc/v10/subscription_transactions.go 80.00% 2 Missing and 4 partials ⚠️
rpc/v9/subscription_transactions.go 79.31% 2 Missing and 4 partials ⚠️
rpc/v10/subscription_receipts.go 83.87% 2 Missing and 3 partials ⚠️
rpc/v9/subscription_receipts.go 83.87% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@                       Coverage Diff                        @@
##             feat/integ-multi-preconfirmed    #3778   +/-   ##
================================================================
  Coverage                                 ?   76.20%           
================================================================
  Files                                    ?      438           
  Lines                                    ?    39320           
  Branches                                 ?        0           
================================================================
  Hits                                     ?    29963           
  Misses                                   ?     7205           
  Partials                                 ?     2152           

☔ 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.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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


Review: refactor(rpc): replace subscription cache with pre_confirmed tip deduper

  • Read the new PreConfirmedDeduper + test
  • Review v10 subscription changes (events / receipts / transactions)
  • Review v9 subscription changes
  • Check deduper wiring, concurrency, edge cases
  • Post findings

Verdict: clean — no blockers or important issues found. The simplification is sound and I verified the assumptions it rests on.

Why it holds up

  • Handoff has no lost notifications and no canonical duplicate. SubscribeKeepLast (feed/feed.go:62) only delivers events published after subscribe, and feeds are subscribed before onStart runs (subscriptions.go:65-89). Historical replay is bounded to the canonical head, onNewHead stays registered, and onPreConfirmed re-publishes the full tip on each delta — so the tip arrives live, and anything missed in the gap eventually lands as canonical. Since a committed block is only ever published once to newHeads, bypassing the deduper for canonical blocks is safe.
  • Round scoping is correct. MarkSent resets seen whenever (block.Number, BlockIdentifier) changes (preconfirmed_deduper.go:26-30), and BlockIdentifier is a non-empty string that stays stable within a round and changes on same-height replacement (core/pending/pending.go:66). Monotonic pre_confirmed feed → single-round dedup never thrashes.
  • No dedup regression. Within a pre_confirmed round the finality status is constant (TxnPreConfirmed), so the old value-cache-on-status behaviour is equivalent to key-only dedup. Dropping TransactionIndex from the receipt key is fine — tx hashes are unique within a block.
  • Concurrency: the deduper is per-subscription and only ever touched from the single dispatch goroutine's select loop; the documented lock-free/not-concurrent-safe contract matches usage. ✅
  • Tests cover same-round dedup, block advance, same-height identifier change, Clear, and block-zero; the subscription tests were updated to exercise the feed-driven handoff. ✅

Nits (non-blocking, no action needed)

  • MarkSent dereferences *key; receipt/transaction call sites pass (*felt.TransactionHash)(receipt.Hash) / (txn.Hash), so a nil hash would panic. Not a regression (the old code dereferenced the same hashes) and pre_confirmed txns always carry a hash — purely theoretical.
  • Minor behavioural change: a pre_confirmed subscriber no longer gets the current tip synchronously during historical replay; it now arrives on the next delta (or as canonical). This is a small latency shift, consistent with the "may be received multiple times, or not at all" contract — worth being aware of but not a bug.

⚠️ go build ./rpc/... passed cleanly, but go test is blocked by the CI sandbox permissions here — please confirm CI is green before merge.
· refactor/subscription-preconfirmed-deduper

@EgeCaner EgeCaner force-pushed the feat/integ-multi-preconfirmed branch 2 times, most recently from a4f2629 to 895ca24 Compare July 2, 2026 19:25
@EgeCaner EgeCaner force-pushed the refactor/subscription-preconfirmed-deduper branch from 95c2078 to f7c2d5f Compare July 2, 2026 19:44
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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


Review: refactor(rpc): replace subscription cache with pre_confirmed tip deduper

  • Read the new PreConfirmedDeduper + test
  • Review v10 subscription changes (events / receipts / transactions)
  • Review v9 subscription changes
  • Compare against old SubscriptionCache semantics
  • Check deduper wiring, concurrency, edge cases
  • Post findings

Verdict: sound refactor, one nuance worth a decision. The move from the 3-slot value cache to a single-round key-only PreConfirmedDeduper is well justified and the core assumptions hold (go build ./rpc/... passes). I have one substantive observation about the just-pushed docs commit, and the rest is clean.

Important / worth a decision

  • The removed "boundary-duplicate" note is not actually stale (rpc/v9/subscription_events.go:152, inline). The old cache routed both historical replay and onNewHead through the same block-keyed cache, so the boundary block N was deduped and never re-emitted. The new design bypasses the deduper for both paths, so given the syncer's store→publish ordering (sync/sync.go:359 sets DB head, sync/sync.go:381 publishes to newHeads), a subscription that sets up in that window can receive block N from the KeepLast newHeads sub and from historical replay → the same ACCEPTED_ON_L2 events twice. It's a narrow race and may well be acceptable (the author's original note said as much), but the follow-up commit reframes it as impossible ("stale"), which isn't accurate. I'd reword the note rather than delete it, or add a cheap boundary-block guard in onNewHead. Same applies to rpc/v10/subscription_events.go:143.

Verified sound

  • Round scopingMarkSent resets on (block.Number, BlockIdentifier) change; the pre_confirmed feed is monotonic (SubscribeKeepLast off a single feed), so a single-round deduper never thrashes. ✅
  • No pre_confirmed dedup regression — status is constant (TxnPreConfirmed) within a round, so key-only dedup matches the old value-cache-on-status behaviour; dropping TransactionIndex from the receipt key is fine (tx hashes unique per block). ✅
  • Receipts / transactions have no onStart historical replay, so the boundary overlap above does not affect them — canonical bypass is safe there. ✅
  • Concurrency — deduper is per-subscription, touched only from the single dispatch select loop; the documented lock-free/not-concurrent-safe contract matches usage. ✅
  • Tests cover same-round dedup, block advance, same-height identifier change, Clear, block-zero, and the feed-driven handoff. ✅

Nit (non-blocking)

  • MarkSent dereferences *key; receipt/transaction call sites pass (*felt.TransactionHash)(receipt.Hash) / (txn.Hash), so a nil hash would panic. Not a regression and pre_confirmed txns always carry a hash — purely theoretical.

⚠️ go build ./rpc/... passes; I couldn't run go test in this sandbox — please confirm CI is green before merge.
· refactor/subscription-preconfirmed-deduper

head *core.Block,
) error {
return s.processBlock(ctx, id, head, TxnAcceptedOnL2)
// Canonical blocks bypass the deduper: they are published once.

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.

The boundary-block duplicate this commit removes the note for isn't actually stale — it's still reachable, and the old cache used to suppress it.

In the old code both the historical replay (sendEventWithoutDuplicate) and onNewHead went through the SubscriptionCache, keyed on block number. The boundary block N (= head at subscribe time) was cached during replay with TxnAcceptedOnL2, so a subsequent onNewHead(N) hit ShouldSendfalse and was suppressed. In the new design, historical replay sends "directly without the deduper" and canonical blocks "bypass the deduper", so nothing suppresses a re-delivery of N.

That re-delivery is still possible because of the store→publish ordering in the syncer:

sync/sync.go:359  committedBlock.Persisted <- nil   // DB head becomes N (time A)
sync/sync.go:381  s.newHeads.Send(block)            // newHeads publishes N (time B)

If a subscription sets up in the A < T0 < T1 < B window (resolveBlockRange reads head N at T0, then SubscribeKeepLast(newHeads) registers at T1 before Send(N) fires at B), the KeepLast sub receives N, historical replay also covers up to N, and block N's events get emitted twice as ACCEPTED_ON_L2. Narrow race, but non-zero.

So the behaviour the earlier comment called "acceptable" is a real, if rare, regression vs. the old cache — not stale. I'd either restore a reworded note (the dup is now possible rather than suppressed, and deemed acceptable) or add a cheap guard skipping the boundary block in onNewHead when it was already replayed. Same applies to rpc/v10/subscription_events.go:143.

Fix this →

@EgeCaner EgeCaner force-pushed the feat/integ-multi-preconfirmed branch from 895ca24 to fdfff54 Compare July 3, 2026 10:55
@EgeCaner EgeCaner force-pushed the refactor/subscription-preconfirmed-deduper branch from f7c2d5f to 9f94e4e Compare July 3, 2026 10:55
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @EgeCaner's task in 2m 45s —— View job


Review: refactor(rpc): replace subscription cache with pre_confirmed tip deduper

  • Read PreConfirmedDeduper + test
  • Review v10 subscription changes (events / receipts / transactions)
  • Review v9 subscription changes
  • Re-examine the boundary-duplicate note that was just dropped
  • Check deduper wiring, concurrency, edge cases

Verdict: sound refactor, but the latest docs commit (9f94e4ee6) is wrong. Swapping the 3-slot value cache for a single-round key-only PreConfirmedDeduper is well justified and the core code holds up (go build ./rpc/... passes). One important issue with the just-pushed comment change; the rest is clean.

Important

  • The dropped "boundary-duplicate" note is not staleinline on rpc/v10/subscription_events.go:143 (same applies to rpc/v9/subscription_events.go:152). The old cache routed both historical replay and onNewHead through the block-keyed cache, so it actively suppressed a re-delivery of the boundary block N. The new design bypasses the deduper on both paths, so given the syncer's store→publish gap (sync/sync.go:359 sets DB head, sync/sync.go:381 publishes to newHeads) a subscription that reads head N and registers its KeepLast newHeads sub inside that window will emit block N's events twice as ACCEPTED_ON_L2. Narrow race, but real — commit 9f94e4ee6 reframes it as impossible, which isn't accurate. I'd restore a reworded note (duplicate now possible but accepted) rather than delete it, or add a reorg-safe boundary guard in onNewHead. Only the events subscription is affected.

Verified sound

  • Round scopingMarkSent resets seen on (block.Number, BlockIdentifier) change (preconfirmed_deduper.go:26); the pre_confirmed feed is monotonic (single feed via SubscribeKeepLast), so a single-round deduper never thrashes. ✅
  • No pre_confirmed dedup regression — finality status is constant (TxnPreConfirmed) within a round, so key-only dedup matches the old value-cache-on-status behaviour; dropping TransactionIndex from the receipt key is fine (tx hashes are unique per block). ✅
  • Receipts / transactions have no onStart replay, so the boundary overlap above does not affect them — canonical bypass is safe there. ✅
  • Concurrency — deduper is per-subscription, touched only from the single dispatch select loop; the documented lock-free / not-concurrent-safe contract matches usage. ✅
  • Tests cover same-round dedup, block advance, same-height identifier change, Clear, block-zero, and the feed-driven handoff. ✅

Nit (non-blocking)

  • MarkSent dereferences *key; receipt/transaction call sites pass (*felt.TransactionHash)(receipt.Hash) / (txn.Hash), so a nil hash would panic. Not a regression (the old code dereferenced the same hashes) and pre_confirmed txns always carry a hash — purely theoretical.

⚠️ go build ./rpc/... passes; go test is blocked by the CI sandbox here — please confirm CI is green before merge.
· refactor/subscription-preconfirmed-deduper

head *core.Block,
) error {
return s.processBlock(ctx, id, head, TxnAcceptedOnL2)
// Canonical blocks bypass the deduper: they are published once.

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.

The boundary-block duplicate this comment (commit 9f94e4ee6) reframes as gone isn't actually stale — it's still reachable, and the old cache used to suppress it.

In the old code both historical replay and onNewHead routed through the block-keyed SubscriptionCache. The boundary block N (= head read at subscribe time) was Put during replay with TxnAcceptedOnL2, so a later onNewHead(N) hit ShouldSendexistingValue == valuefalse and was suppressed (subscription_cache.go ShouldSend, old sendEventWithoutDuplicate). In the new design replay sends "directly without the deduper" and onNewHead "bypasses the deduper", so nothing suppresses a re-delivery of N.

The re-delivery is reachable via the syncer's store→publish gap:

sync/sync.go:359  committedBlock.Persisted <- nil   // DB head becomes N
sync/sync.go:381  s.newHeads.Send(block)            // newHeads publishes N

SubscribeKeepLast only delivers events published after subscribe (feed/feed.go:68), so if a subscription's resolveBlockRangeHeadsHeader reads N (after store) and its SubscribeKeepLast(newHeads) registers before Send(N) fires, replay covers up to N and the KeepLast sub later delivers N → block N's events emitted twice as ACCEPTED_ON_L2. Narrow race, but non-zero — and only the events subscription is affected (receipts/transactions have no onStart replay).

So the earlier "acceptable duplicate" note described real behaviour; the follow-up commit calling it stale is inaccurate. I'd restore a reworded note rather than delete it:

Suggested change
// Canonical blocks bypass the deduper: they are published once.
// Canonical blocks bypass the deduper: they are published exactly once. The single
// boundary block at the historical handoff can still be delivered twice (replay covers
// up to the head read at subscribe time, and the KeepLast newHeads sub may also deliver
// it if it registers before sync publishes that block) — this rare duplicate is accepted.

Alternatively add a boundary guard in onNewHead, but note it must reset on reorg (a naive head.Number <= replayedHead skip would wrongly drop blocks re-delivered after a reorg below N). Same applies to rpc/v9/subscription_events.go:152.

@thiagodeev thiagodeev 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.
Any ideais of how can we improve our code to make this not happen?
#3778 (comment)

@rodrodros rodrodros force-pushed the feat/integ-multi-preconfirmed branch from fdfff54 to 1e94b7d Compare July 6, 2026 16:37
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