Skip to content

feat(sync): delta-aware pre_confirmed polling#3617

Open
thiagodeev wants to merge 35 commits into
mainfrom
thiagodeev/feeder-preconfirmed-optimization
Open

feat(sync): delta-aware pre_confirmed polling#3617
thiagodeev wants to merge 35 commits into
mainfrom
thiagodeev/feeder-preconfirmed-optimization

Conversation

@thiagodeev
Copy link
Copy Markdown
Contributor

@thiagodeev thiagodeev commented May 8, 2026

A follow-up work of #3601, implementing another feeder improvement.

Delta sync for get_preconfirmed_block — the poller now echoes knownBlockIdentifier and knownTransactionCount on every tick so the feeder gateway can answer with a no-change marker, a delta of appended transactions/receipts/state diffs, or a full block when the round identifier no longer matches. The sync layer reconciles the three response shapes against the stored pre_confirmed instead of unconditionally re-decoding the whole block on every poll.
The PR also uses the FeederAdapter type from the starknetdata pkg to make the node work normally with both old and new versions of the feeder, switching to the new approach as soon as the new feeder updates are detected in the network.

Reference: https://demerzelsolutions.slack.com/archives/C03090TS3TK/p1777375140952279

Note: candidate txs are no longer supported with this new update, but since old sequencer still uses it, it can only be fully removed in the future.

@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-optimizations branch from b9faf76 to b730843 Compare May 8, 2026 13:21
@thiagodeev thiagodeev added the disable-deploy-test We don't want to run deploy tests with this PR because it might affect our development environment. label May 8, 2026
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-preconfirmed-optimization branch from 66008f8 to 06fcfc6 Compare May 8, 2026 13:26
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-optimizations branch from b730843 to d71d865 Compare May 14, 2026 01:29
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-preconfirmed-optimization branch from 33f712b to 19fc21e Compare May 14, 2026 01:29
@codecov
Copy link
Copy Markdown

codecov Bot commented May 18, 2026

Codecov Report

❌ Patch coverage is 86.29442% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.51%. Comparing base (b44be8e) to head (a5e3520).

Files with missing lines Patch % Lines
adapters/sn2core/sn2core.go 78.26% 5 Missing and 5 partials ⚠️
sync/pending_polling.go 85.41% 5 Missing and 2 partials ⚠️
clients/feeder/feeder.go 71.42% 4 Missing ⚠️
starknetdata/feeder/feeder_adapter.go 76.47% 2 Missing and 2 partials ⚠️
core/pending/pending.go 93.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3617      +/-   ##
==========================================
+ Coverage   76.44%   76.51%   +0.06%     
==========================================
  Files         402      402              
  Lines       36770    36952     +182     
==========================================
+ Hits        28110    28273     +163     
- Misses       6681     6697      +16     
- Partials     1979     1982       +3     

☔ View full report in Codecov by Sentry.
📢 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.

@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-optimizations branch from d71d865 to 9401d3e Compare May 19, 2026 10:23
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-preconfirmed-optimization branch from 2a763f5 to 6fb8a06 Compare May 19, 2026 10:30
@thiagodeev thiagodeev changed the title Thiagodeev/feeder-preconfirmed-optimization feat(sync): delta-aware pre_confirmed polling May 19, 2026
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-optimizations branch 2 times, most recently from a5252de to 4501be1 Compare May 21, 2026 02:21
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-preconfirmed-optimization branch from 5896d33 to b65e155 Compare May 21, 2026 02:23
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-optimizations branch 2 times, most recently from 1afc808 to d6ebb1b Compare May 21, 2026 20:21
@RafaelGranza RafaelGranza self-requested a review May 22, 2026 04:23
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-optimizations branch 4 times, most recently from 8e6ac2d to f5bac4a Compare May 26, 2026 13:30
@RafaelGranza RafaelGranza force-pushed the thiagodeev/feeder-optimizations branch from 373dbaa to cc09277 Compare May 26, 2026 14:03
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-optimizations branch 2 times, most recently from 1d194fc to 5f6a399 Compare May 26, 2026 16:21
Base automatically changed from thiagodeev/feeder-optimizations to main May 26, 2026 16:36
Copy link
Copy Markdown
Contributor

@RafaelGranza RafaelGranza left a comment

Choose a reason for hiding this comment

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

LGTM. I only left a few nits.

I'll halt the approve because I'm waiting for the decision mechanism, which I think may be similar to the includeSignature one.

Comment thread starknetdata/feeder/feeder.go Outdated
Comment thread core/pending/pending.go
Comment thread adapters/sn2core/sn2core.go
Comment thread utils/slices.go Outdated
Comment thread core/pending/pending.go Outdated
Comment thread sync/pending_polling.go Outdated
Comment thread sync/pending_polling.go Outdated
Comment thread sync/pending_polling.go
Comment thread sync/pending_polling_test.go Outdated
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-preconfirmed-optimization branch 4 times, most recently from 1ac6058 to c71f3f3 Compare May 29, 2026 20:27
@thiagodeev thiagodeev removed the disable-deploy-test We don't want to run deploy tests with this PR because it might affect our development environment. label May 29, 2026
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-preconfirmed-optimization branch from c71f3f3 to 807c481 Compare May 29, 2026 20:28
@thiagodeev thiagodeev force-pushed the thiagodeev/feeder-preconfirmed-optimization branch from 807c481 to 7cb445d Compare June 1, 2026 13:26
@thiagodeev thiagodeev deployed to Development June 1, 2026 20:17 — with GitHub Actions Active
@thiagodeev thiagodeev marked this pull request as ready for review June 1, 2026 20:39
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

Claude finished @thiagodeev's task in 7m 3s —— View job


PR Review

  • Read changed files in clients/feeder/
  • Read changed files in starknetdata/feeder/
  • Read changed files in core/pending/
  • Read changed files in sync/
  • Read changed files in adapters/sn2core/
  • Identify correctness/concurrency/error-handling issues
  • Post inline comments for specific findings
  • Post summary

Summary

The delta-aware polling design is clean and the reconciler in handlePreConfirmed correctly handles all three response modes (NoChange, Delta, Full) including the identifier-drift edge case. The FeederAdapter transition mechanism is well-tested. A few concerns below — none are blockers.

Important

  • Nil-deref risk in AdaptPreConfirmedDelta (sn2core.go:651) — passing a nil *starknet.StateDiff to AdaptStateDiff panics on line 451. Legacy path guards via IsCandidateTx; delta path has no guard. Relies entirely on the upstream invariant — worth either an explicit nil check or a comment documenting the assumption.

Nits

  • Non-atomic snapshot in pollPreConfirmed (pending_polling.go:282) — targetBlockNum and currentPreConf can drift if the target advances mid-loop. Self-heals via Full response, so just a wasted poll, but skipping the tick when they disagree would tighten it.
  • Legacy-path identifier stamping (feeder_adapter.go:150) — BlockIdentifier = blockIdentifier makes the round-changed branch in shouldPreservePreConfirmed unreachable on legacy. Intended behavior, but it took a careful read to confirm. A short comment would help.
  • runVerificationLoop retry cadence — not part of this PR's diff, but worth flagging: the 30-min interval with no shorter initial backoff means one transient first-probe failure costs 30 min on the slower legacy path. Low priority follow-up.

Other observations (no action needed)

  • The Copy() before the delta-drift branch in handlePreConfirmed (pending_polling.go:376) is justified by the nextPreConfirmed = existing assignment in the drift branch (already discussed in the existing review thread) — good.
  • shouldPreservePreConfirmed correctly handles the round-restart case raised in the earlier review — confirmed identifier comparison is in place.
  • Concurrency between the three handlers (handleHead, handlePreLatest, handlePreConfirmed) is serialized through pollPendingData's single goroutine, so no internal race. Only cross-goroutine read is the snapshot in pollPreConfirmed, addressed above.
  • Test coverage for the Full → Delta → New identifier → NoChange sequence in TestPollPendingData is solid.
    • Branch: thiagodeev/feeder-preconfirmed-optimization

return nil, nil, nil, err
}
var stateDiff core.StateDiff
stateDiff, err = AdaptStateDiff(response.TransactionStateDiffs[i])
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.

Potential nil-pointer panic if delta contains nil state diffs.

AdaptStateDiff immediately dereferences response.OldDeclaredContracts at the top of its body (sn2core.go:451), so passing a nil *starknet.StateDiff will panic. The legacy AdaptPreConfirmedBlock (above) guards against this via IsCandidateTx, but AdaptPreConfirmedDelta has no such guard — it relies entirely on the upstream invariant that delta responses never include candidate-style entries.

If the new feeder ever ships a malformed delta (e.g., padded with nil placeholders), this loop will panic the sync goroutine instead of returning a clean error. Worth either:

  • adding an early if response.TransactionStateDiffs[i] == nil { return ..., errors.New("nil state diff in delta") }, or
  • documenting the upstream guarantee explicitly here so the brittleness is intentional.

Same concern would apply to response.Receipts[i], but AdaptTransactionReceipt already nil-checks (sn2core.go:68), so receipts are safe.

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.

IMO, not relevant.
Delta responses are by definition never empty. If the sequencer breaks its response contract, everything else breaks

Comment thread sync/pending_polling.go
if currentPreConf != nil {
blockIdentifier = currentPreConf.BlockIdentifier
knownTransactionCount = uint64(len(currentPreConf.Block.Transactions))
}
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.

Minor: non-atomic snapshot of targetBlockNum and currentPreConf.

blockNumberToPoll.Load() (line 267) and s.preConfirmed.Load() (line 278) aren't taken together, so they can disagree if pollPendingData advances the target (via handleHead/handlePreLatest) and replaces the stored pre_confirmed in between. The worst case is sending (targetBlockNum=N+1, blockIdentifier=oldId_for_N, txCount=oldCount_for_N): the server will see the identifier mismatch and return Full, which the reconciler then accepts — so it self-heals after one wasted round trip. Not a correctness bug, just a one-tick stale poll.

If you want to tighten it, you can either skip the poll when currentPreConf != nil && currentPreConf.Block.Number != targetBlockNum, or just derive targetBlockNum from currentPreConf when present. Up to you whether it's worth the noise.

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.

Not relevant IMO.
As said, if a new PreConfirmed block is stored between block number load and currentPreConfirmed load, and the node requests block X with a different identifier, the sequencer will return the full block, and the code will handle it properly

if err != nil {
return pending.PreConfirmedUpdate{}, err
}
adaptedPreConfirmed.BlockIdentifier = blockIdentifier
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.

Subtle: stamping the caller's identifier onto a legacy-path Full block.

On the legacy fallback, the upstream get_preconfirmed_block endpoint doesn't return an identifier, but we set adaptedPreConfirmed.BlockIdentifier = blockIdentifier (the caller's previously stored identifier — typically "0x0" on first poll, or whatever was stored before).

The downstream effect is that shouldPreservePreConfirmed (sync/pending_polling.go:54) will treat the new block as having the same identifier as the stored one, so the "round changed" branch is effectively unreachable on the legacy path. The reconciler then falls back to comparing TransactionCount only — which is the intended legacy behavior, but it took a careful read to figure that out.

A short comment here explaining the round-identifier semantics on the legacy path (or, alternatively, leaving BlockIdentifier empty/"legacy" so the intent is explicit) would save future maintainers a re-read.

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.

Not relevant IMO.
This code (FeederAdapter) will only live for a month, no need to be that strict for "future maintenance"

Copy link
Copy Markdown
Contributor

@RafaelGranza RafaelGranza left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Copy Markdown
Contributor

@rodrodros rodrodros left a comment

Choose a reason for hiding this comment

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

This PR is wrong on several levels.

Architecture wise:

  • One type containing three types instead of using Go's Polymorphism.
  • Constructors that are called and then fields being initialized separately
  • Two overlapping types sharing the same field
  • Adapter functions not building the right type instead 3 arrays.

Style wise is also wrong with many style decisions that have been discussed numerous times and not being followed here.

Comment on lines +612 to +613
preConfirmed := pending.NewPreConfirmed(adaptedBlock, &stateUpdate, txStateDiffs, candidateTxs)
preConfirmed.BlockIdentifier = response.BlockIdentifier
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.

This doesn't look good, calling the constructor for then setting a property.

Comment thread core/pending/pending.go
Comment on lines +78 to +90
const (
// PreConfirmedNoChange means the server's pre_confirmed matches what the
// caller already has and no further action is required.
PreConfirmedNoChange PreConfirmedUpdateMode = iota
// PreConfirmedDelta means new transactions/receipts/state diffs have been
// appended since the caller's known transaction count and should be merged
// onto the existing stored pre_confirmed.
PreConfirmedDelta
// PreConfirmedFull means the server's pre_confirmed is for a different
// round and the caller should discard existing data and replace it with
// Full.
PreConfirmedFull
)
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.

Wouldn't you say that every function called PreConfirmedSmth is kind of extra, why not call it just Smth.

Comment thread core/pending/pending.go
// BlockIdentifier is an identifier returned by the feeder gateway
// that uniquely identifies the current round of the pre_confirmed block.
// It is used to negotiate delta-sync responses on subsequent polls.
BlockIdentifier string
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.

As far as I can see BlockIdentifier is not a string but always a smaller than a uint64. Why are you storing a string?

Comment thread core/pending/pending.go
Comment on lines +97 to +100
BlockIdentifier string

// FullBlock is set when Mode == PreConfirmedFull.
FullBlock *PreConfirmed
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.

Why BlockIdentifier is defined twice, once here and nother inside PreConfirmed?

Comment thread core/pending/pending.go
// PreConfirmedUpdate is the result of a delta-aware pre_confirmed fetch. It
// is the boundary type between data sources and the sync layer reconciler.
// Only the fields appropriate for Mode are populated.
type PreConfirmedUpdate struct {
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.

This description is non descriptive and explained in unnecessary complex vocabulary. What is a sync layer reconciler? Only the fileds appropriate for Mode are populated <- why does this sentece adds?

Update to:

// PreConfirmedUpdate represents the three types of responses we can have when fetching for 
// a pre-confirmed block:
//  1. NoChange the struct is empty
//  2. PreConfirmedDelta smth smth
//  3. smth smth

Comment on lines +631 to +637
isInvalidPayloadSizes := len(response.Transactions) != len(response.TransactionStateDiffs) ||
len(response.Transactions) != len(response.Receipts)
if isInvalidPayloadSizes {
return nil, nil, nil, errors.New(
"invalid sizes of transactions, state diffs and receipts",
)
}
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.

Why don't you add information to the error such as which size were expected, of what is the miss-match.

Comment on lines +627 to +629
if response == nil {
return nil, nil, nil, errors.New("nil preconfirmed block")
}
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.

Remove, tired of mentioning the same thing

Comment on lines +617 to +626
// AdaptPreConfirmedDelta extracts the per-transaction core types from a delta
// 'get_preconfirmed_block' response.
func AdaptPreConfirmedDelta(
response *starknet.PreConfirmedBlock,
) (
txns []core.Transaction,
receipts []*core.TransactionReceipt,
txStateDiffs []*core.StateDiff,
err error,
) {
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.

I don't see any adaptation, just three types being returned? Shouldn't it return the DeltaUpdated type?

Comment thread core/pending/pending.go
Comment on lines +137 to +146
// ApplyDelta returns a new PreConfirmed by appending the given transactions,
// receipts, and merging the state diffs onto the
// existing PreConfirmed. It does not modify the receiver.
func (p *PreConfirmed) ApplyDelta(
txs []core.Transaction,
receipts []*core.TransactionReceipt,
txStateDiffs []*core.StateDiff,
blockIdentifier string,
) *PreConfirmed {
next := *p
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.

Question: Why does this function returns a new PreConfirmed instead of re-using the current one. And if it is necessary to create a new one, why does it returns a reference to it, instead of returning it by value?

Comment thread core/pending/pending.go
Comment on lines +171 to +175
newStateDiff := core.EmptyStateDiff()
newStateDiff.Merge(p.StateUpdate.StateDiff)
for _, sd := range txStateDiffs {
newStateDiff.Merge(sd)
}
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.

Re-mergeing all the state diffs again seems awfully inneficient. At least start from a copy of the other state diff

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.

3 participants