Skip to content

feat(sync): multi-block pre_confirmed chain, poller + storage#3723

Open
EgeCaner wants to merge 10 commits into
mainfrom
feat/multiple-preconfirmed-blocks
Open

feat(sync): multi-block pre_confirmed chain, poller + storage#3723
EgeCaner wants to merge 10 commits into
mainfrom
feat/multiple-preconfirmed-blocks

Conversation

@EgeCaner

Copy link
Copy Markdown
Contributor

Summary

This PR implements poller and storage in order to support multiple preconfirmed. Currently multi preconfirmed logic is not wired to production. Following PR will wire it to production and will remove prelatest, and candidate transactions.

  • Add PreConfirmedBlockLatest to the feeder client and propagate it through
    starknetdata.StarknetData and sync.DataSource. This hits the sequencer's
    "latest" variant of get_preconfirmed_block, whose response carries a
    top-level block_number so the caller can discover the server's
    pre_confirmed tip without tracking the height itself. The shared
    PreConfirmedUpdateEnvelope now decodes that field.
  • Introduce the sync/preconfirmed package with two pieces:
    • ChainStorage — an immutable, lock-free linked list of pre_confirmed
      blocks anchored above the canonical head. Single writer (the poller) /
      many concurrent readers via atomic.Pointer. Exposes SnapshotForHead
      (bounded by BlockHashLag) for consumers, UnsafeSnapshot for the
      poller, and ApplyUpdate / AdvanceTo for chain evolution. Handles
      bootstrap, extension, in-chain replacement (Delta or new round), and
      realignment when head moves or reverts.
    • Poller — single-goroutine tick loop: advance storage to head,
      poll the server's latest, backfill any gap below it (re-polling the
      previous tip with delta hints to capture its final view), then apply
      the latest update. NoChange / Delta / Full variants all dispatched.

@EgeCaner EgeCaner requested review from rodrodros and thiagodeev June 12, 2026 15:54
@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

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


Claude PR Review

Reviewed the multi-block pre_confirmed poller + storage. This is a clean, carefully-engineered PR — the ChainStorage immutability/single-writer model is sound, the doc comments are excellent, and the test suite is thorough (bootstrap, extend, in-chain replace, reorg-at-depth, snapshot-survives-update, BlockHashLag capping, cold/warm poller paths). Build passes locally; tests couldn't be run here due to sandbox permissions but use testing/synctest so the time.Sleep(tickInterval) calls are deterministic, not flaky.

Correctness / concurrency

I traced the tickbackfillapply flow and the ChainStorage CAS paths and did not find a hard bug:

  • Single-writer (poller) + atomic.Pointer lock-free reads is consistent; ApplyUpdate/AdvanceTo Load→CAS is safe given the single writer, and CAS-failure surfaces as an error rather than corrupting state.
  • The stale-head window (head commits between tick's read and apply) is correctly absorbed by SnapshotForHead's lower-bound trimming and the next tick's AdvanceTo — nicely handled.
  • Underflow-prone subtractions (depthFromHead, viewTip - wantBottom) are all guarded by prior range checks.
  • Poller goroutine exits cleanly on ctx.Done(); ticker stopped via defer. No leaks.

Findings (all nit severity — nothing blocking)

  1. chain_storage.go OldestFirst/walkOldestFirst — recursion depth is only ≤ BlockHashLag for SnapshotForHead views; on an UnsafeSnapshot it equals the uncapped chain length. Not live today, but the follow-up wiring PR adds consumers. (inline)
  2. poller.go backfill — partial-progress entries are published on the feed before a later error aborts the tick; the next tick can re-publish them. Correct recovery, but downstream consumers must be idempotent — worth stating explicitly. (inline)

Optional

  • A -race test exercising a concurrent reader walking a snapshot while the poller applies/advances would lock in the lock-free-read invariant (the snapshot survives subsequent updates / pre-reorg snapshot walks the old chain tests already cover the immutability guarantee single-threaded).

Nice work — the storage abstraction is clean and the comments make the invariants easy to verify.
· feat/multiple-preconfirmed-blocks

Comment on lines +77 to +84
func (c *ChainReader) OldestFirst() iter.Seq[*pending.PreConfirmed] {
return func(yield func(*pending.PreConfirmed) bool) {
if c == nil {
return
}
walkOldestFirst(c.head, c.length, yield)
}
}

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.

Nit (unbounded recursion on unsafe snapshots). The doc here and on walkOldestFirst justifies the recursion depth as "at most BlockHashLag", but that bound only holds for head-aligned views produced by SnapshotForHead. UnsafeSnapshot returns the full, uncapped chain (length = however far the sequencer has run ahead). OldestFirst / PendingStateAt / PendingStateBeforeIndexAt are exported and operate on any *ChainReader, so if a consumer ever iterates an unsafe snapshot the recursion depth equals the raw chain length, not BlockHashLag.

In practice the gap is small and this PR doesn't wire consumers yet, so it's not a live bug — but since the next PR adds consumers, consider either iterating non-recursively or asserting/documenting that these methods must only be called on SnapshotForHead views.

Comment on lines +158 to +183
func (p *Poller) backfill(
ctx context.Context,
head *core.Header,
fromBlock uint64,
identifier string,
txCount uint64,
endExclusive uint64,
) error {
update, err := p.dataSource.PreConfirmedBlockByNumber(ctx, fromBlock, identifier, txCount)
if err != nil {
return fmt.Errorf("polling pre_confirmed by number %d: %w", fromBlock, err)
}
if err := p.apply(update, fromBlock, txCount, head); err != nil {
return fmt.Errorf("backfilling pre_confirmed at %d: %w", fromBlock, err)
}
for n := fromBlock + 1; n < endExclusive; n++ {
update, err := p.dataSource.PreConfirmedBlockByNumber(ctx, n, "", 0)
if err != nil {
return fmt.Errorf("polling pre_confirmed by number %d: %w", n, err)
}
if err := p.apply(update, n, 0, head); err != nil {
return fmt.Errorf("backfilling pre_confirmed at %d: %w", n, err)
}
}
return nil
}

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.

Nit (partial-progress re-publish). Each apply inside backfill calls p.out.Send(applied) as it goes. If a later iteration of the backfill loop (or the final apply in tick) errors out, the entries already applied this tick stay committed to storage and have already been published on the feed. The next tick re-advances, re-snapshots (now with mostRecent at the last successfully-applied block) and resumes — which is the right recovery behavior, but a block whose apply succeeds on tick N and a re-poll on tick N+1 can be published twice.

That's fine if feed consumers are idempotent on (block_number, identifier, tx_count). Worth a one-line comment here making that contract explicit, since downstream consumers land in the follow-up PR.

@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.27%. Comparing base (7f0ef79) to head (30ab42e).

Files with missing lines Patch % Lines
sync/preconfirmed/chain_storage.go 86.80% 16 Missing and 15 partials ⚠️
sync/preconfirmed/poller.go 79.01% 10 Missing and 7 partials ⚠️
clients/feeder/feeder.go 90.47% 1 Missing and 1 partial ⚠️
starknetdata/feeder/feeder.go 0.00% 2 Missing ⚠️
sync/data_source.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3723      +/-   ##
==========================================
+ Coverage   75.11%   75.27%   +0.15%     
==========================================
  Files         432      434       +2     
  Lines       38446    38787     +341     
==========================================
+ Hits        28880    29196     +316     
- Misses       7568     7574       +6     
- Partials     1998     2017      +19     

☔ 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 Jun 12, 2026

Copy link
Copy Markdown
Contributor

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


Claude PR Review

  • Read changed files (feeder, starknet, sync/preconfirmed)
  • Analyze chain_storage.go (concurrency, recursion, CAS)
  • Analyze poller.go (tick/backfill/apply flow)
  • Analyze feeder/data-source propagation + error handling
  • Review tests for meaningful assertions / flakiness
  • Post summary

Reviewed the multi-block pre_confirmed poller + storage. No blocking or important issues found. The immutable single-writer / lock-free-reader model is sound and the invariants are well-documented. Build passes locally (go build ./sync/... ./clients/... ./starknet/... ./starknetdata/...). I'm skipping inline comments because the only two real findings are already filed on this PR (I concur with both).

What I independently verified

  • Immutability holds end-to-end. AdaptPreConfirmedWithDelta (adapters/sn2core/sn2core.go:629) allocates fresh tx/receipt/state-diff slices and copies — it never mutates current in place, so concurrent readers walking a prior *ChainReader snapshot keep a stable graph. rebuild/extend/replaceSlot likewise only produce new nodes. ✅
  • No poller stall on slow consumers. feed.Feed.Send (feed/feed.go:68) is non-blocking (select … default), so the single poller goroutine can't be wedged by a downstream subscriber. ✅
  • Bounds/underflow. depthFromHead, viewTip - wantBottom, and Block.Number - (length-1) subtractions are all guarded by prior [bottom, mostRecent] range checks; the replaceSlot parent-walk can't overrun the nil terminator. ✅
  • Stale-head window. head is captured once per tick and reused through apply; if the canonical head advances mid-tick, the resulting above-head entry is trimmed by SnapshotForHead's lower bound and dropped by the next tick's AdvanceTo. ✅
  • CAS-failure surfaces as an error rather than corrupting state (safe given the single writer); Run logs and recovers on the next tick. Goroutine exits cleanly on ctx.Done() with ticker.Stop() deferred — no leak. ✅
  • Tests use testing/synctest, so the time.Sleep(tickInterval) calls run on virtual time and are deterministic, not flaky. Coverage spans cold/warm bootstrap, same-height delta, forward jump, large-gap walk, not-at-tip skip, and error-skips-apply paths.

Known nits (already filed — I concur, nothing blocking)

  1. Unbounded recursion on unsafe snapshotsOldestFirst/walkOldestFirst recursion depth is ≤ BlockHashLag only for SnapshotForHead views; on an UnsafeSnapshot it equals the raw (uncapped) chain length. Not live today; worth bounding or asserting before the follow-up PR wires consumers. (existing thread)
  2. Partial-progress re-publishbackfill Sends each applied entry as it goes; a later error aborts the tick but leaves earlier entries committed + published, and the next tick can re-publish them. Correct recovery, but the feed contract should be stated as idempotent on (block_number, identifier, tx_count). (existing thread)

One thing to add to that idempotency contract

Note that feed.Send drops under backpressure, and SubscribeKeepLast keeps only the most recent value. So during a multi-block backfill a KeepLast consumer may observe only the final entry — combined with finding #2, downstream consumers must tolerate both duplicates and skipped intermediate entries, reconstructing the full chain from SnapshotForHead rather than treating the feed as a gap-free stream. Worth spelling out when the consumer lands in the next PR.

Nice work — the storage abstraction is clean and the doc comments make the invariants easy to verify.

Comment thread clients/feeder/feeder.go
Comment thread clients/feeder/feeder.go Outdated
Comment thread sync/preconfirmed/chain_storage.go Outdated
// newer than it, so concurrent readers walking a prior snapshot see a stable
// graph. Popped nodes become unreferenced and GC-collectable.
type node struct {
pc *pending.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.

Let's use descriptive name, pc can be many things. preconfirmed is a good one

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.

changed as preconfirmed

Comment thread sync/preconfirmed/chain_storage.go Outdated
Comment thread sync/preconfirmed/chain_storage.go Outdated
Comment on lines +61 to +63
if c == nil {
return
}

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 would c be nil, which scenario is this? Why would you call this on a nil pointer?

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.

removed the nil check on receiver

Comment thread sync/preconfirmed/chain_storage.go Outdated
Comment thread sync/preconfirmed/chain_storage.go Outdated
Comment thread sync/preconfirmed/chain_storage.go
@EgeCaner EgeCaner force-pushed the feat/multiple-preconfirmed-blocks branch from c375f1f to b5d58ae Compare June 18, 2026 08:25
@EgeCaner EgeCaner changed the base branch from main to chore/remove-candidate-txs-and-deprecated-pconf-api June 18, 2026 08:26
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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


Claude PR Review

  • Read changed files (feeder, starknet, sync/preconfirmed)
  • Analyze chain_storage.go — concurrency, recursion, nil-receiver checks
  • Analyze poller.go — tick/backfill/apply flow
  • Review feeder/data-source propagation + error handling
  • Check tests for meaningful assertions / flakiness
  • Post review

Re-reviewed the latest commits (b46e79a3f..b5d58ae2d) of the multi-block pre_confirmed poller + storage. Build passes (go build ./sync/preconfirmed/... ./clients/feeder/... ./starknet/... ./starknetdata/...). No blocking or important issues found — the immutable single-writer / lock-free-reader model holds up under re-trace, and the two open nit threads remain the only substantive findings. Skipping inline comments to avoid duplicating the existing threads.

What I re-verified on this push

  • tickbackfillapply forward-jump path. When blockNumber > fromBlock, the latest poll is guaranteed to be a Full block (the NoChange/Delta switch collapses blockNumber to fromBlock, so those variants never reach the jump branch). After backfill advances storage to blockNumber-1, the final apply lands as extend at mostRecent+1 and correctly requires a PreConfirmedBlock. The stale baseTxCount passed to that final apply is harmless because the Full path ignores it — matches the comment at poller.go:144-150. ✅
  • SnapshotForHead bounds. Lower-bound trimming works by shortening length (not re-anchoring viewHead), and OldestFirst/PendingStateAt recurse exactly length deep, so stale below-head+1 nodes are excluded. Upper-bound cap (wantBottom + BlockHashLag - 1) walks viewHead down past sequencer-ahead entries. PendingStateAt's independent bottom recomputation agrees with the capped view. ✅
  • CAS / single-writer. ApplyUpdate/AdvanceTo Load→CAS is safe under the single poller goroutine; CAS failure surfaces as an error rather than corrupting state. Goroutine exits cleanly on ctx.Done() with ticker.Stop() deferred. ✅
  • Cold-start & misbehaving-server edges (server returning NoChange/Delta to a blank request) degrade to a logged no-op + retry, not a panic or corruption. ✅

Open nits (already filed — I concur, nothing blocking)

  1. Unbounded recursion on UnsafeSnapshot viewswalkOldestFirst depth is ≤ BlockHashLag only for SnapshotForHead; on an UnsafeSnapshot it's the raw chain length. Not live yet; worth bounding or asserting before the consumer-wiring PR. (thread)
  2. Partial-progress re-publish in backfill — entries Send as they apply, so a later error leaves earlier entries committed + published and the next tick can re-publish them. Correct recovery, but the feed contract should be documented as idempotent (and, given feed.Send drops under backpressure + SubscribeKeepLast, consumers must tolerate both duplicates and skipped intermediates — reconstruct from SnapshotForHead, don't treat the feed as gap-free). (thread)

On @rodrodros's review threads

  • chain_storage.go:54 — "aren't c == nil and c.length == 0 always true together?" No, they're genuinely distinct. SnapshotForHead returns a value ChainReader{} (head=nil, length=0) in its empty branches; since the methods have pointer receivers, calling e.g. view.Head() on that value takes &view — a non-nil *ChainReader with length == 0. So the || c.length == 0 arm is load-bearing, not redundant. (The c == nil arm covers UnsafeSnapshot(), which returns a literal nil *ChainReader when storage is empty and is dereferenced in poller.tick.) Both paths are reachable, so the checks aren't dead defensive code — though a short doc line saying why (zero-value value-views vs. nil unsafe-snapshots) would settle the question for future readers.
  • Naming (pc, env) and direct unit test for PreConfirmedBlockLatest are reasonable; envenvelope and a focused test asserting BlockNumber propagation are cheap wins. These are author's-call style/coverage items, not correctness.

Nice work — the storage abstraction is clean and the invariants are easy to verify from the doc comments.
· feat/multiple-preconfirmed-blocks

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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


Re-review — multi-block pre_confirmed poller + storage

  • Read changed files & diff vs chore/remove-candidate-txs-and-deprecated-pconf-api
  • Analyze chain_storage.go (concurrency, recursion, removed nil-checks)
  • Analyze poller.go (tick → backfill → apply)
  • Review feeder / data-source propagation
  • Build affected packages
  • Post review

Re-reviewed the latest push (renames envpreConfirmedEnvelope, pcpreconfirmed, removal of receiver nil-checks, NewChain constructor + base-state resolution moved into the chain). No blocking or important issues. Build passes (go build ./sync/preconfirmed/... ./clients/feeder/... ./starknet/... ./starknetdata/... ./sync/...). The immutable single-writer / lock-free-reader model still holds and the two open nit threads remain the only substantive findings — not duplicating them inline.

What I re-verified on this push

  • Removed receiver nil-checks are safe. The only producer of a nil *ChainReader is UnsafeSnapshot() (chain_storage.go:261), and its sole consumer poller.tick guards with if chain != nil && chain.Length() > 0 (poller.go:115) before any method call. SnapshotForHead returns a zero value ChainReader{} (length 0), so its callers always have a non-nil receiver and the c.length == 0 guards in Head/Length/PreConfirmedStateAt/etc. cover the empty case without a c == nil arm. ✅ Consumers aren't wired yet, so if a future caller walks an UnsafeSnapshot it must keep the != nil guard — worth a doc note when that PR lands.
  • Chain-owns-base-resolution refactor is consistent. baseState/PreConfirmedStateAt/PreConfirmedStateBeforeIndexAt each recompute bottom = head.Number − (length−1). For a SnapshotForHead view this equals wantBottom even when storage is transiently stale (lower-bound trim shortens length rather than re-anchoring head), so base resolution opens canonical state at exactly bottom−1. The independent recomputation in each method agrees with the capped view. ✅
  • SnapshotForHead bounds. Lower-bound (wantBottom < storedBottom → empty), stale-below trim (shorter length), and upper-bound cap (wantBottom + BlockHashLag − 1, walking viewHead down past sequencer-ahead entries) all check out; int(viewTip − wantBottom + 1) is guarded by the preceding range checks. ✅
  • tick forward-jump. NoChange/Delta collapse blockNumber to fromBlock, so the jump branch (blockNumber > fromBlock) only ever carries a Full update; backfill advances storage to blockNumber−1 and the final apply lands as extend. The stale baseTxCount is ignored on the Full path (documented at poller.go:144-150). ✅
  • CAS / single-writer & lifecycle. ApplyUpdate/AdvanceTo Load→CAS surface CAS failure as an error rather than corrupting state; Run recovers on the next tick; goroutine exits on ctx.Done() with ticker.Stop() deferred. ✅

On the open threads

  • rodrodros — nil-checks on ChainReader: addressed — receiver nil-checks removed in ae6fd6b, and the remaining c == nil lives only in SnapshotForHead/AdvanceTo/computeUpdate, which operate on the loaded atomic.Pointer value (legitimately nil when storage is empty), not on the receiver. Resolved.
  • rodrodros — naming (env, pc): addressed (preConfirmedEnvelope, preconfirmed).
  • rodrodros — direct unit test for PreConfirmedBlockLatest (feeder.go:403): still worth adding a focused test asserting BlockNumber propagation from the envelope; codecov shows that function at 54% and the latest path uncovered. Cheap win, author's call.
  • Nit CI: Automated deployment of docs on GH Pages #1 (unbounded recursion on UnsafeSnapshot): walkOldestFirst depth is ≤ BlockHashLag only for SnapshotForHead; on an UnsafeSnapshot it's the raw chain length. Not live; bound or assert before the consumer-wiring PR. (thread)
  • Nit Make Juno docs available at gojuno.xyz #2 (partial-progress re-publish in backfill): entries Send as they apply, so a later error leaves earlier entries committed + published and the next tick can re-publish. Correct recovery, but the feed contract should be documented as idempotent — and since feed.Send drops under backpressure and SubscribeKeepLast keeps only the latest, consumers must tolerate both duplicates and skipped intermediates and reconstruct from SnapshotForHead rather than treating the feed as gap-free. (thread)

Nice work — the storage abstraction is clean and the doc comments make the invariants easy to verify.
· feat/multiple-preconfirmed-blocks

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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


Re-review — multi-block pre_confirmed poller + storage

  • Read latest diff vs chore/remove-candidate-txs-and-deprecated-pconf-api
  • Re-check chain_storage.go (UnsafeSnapshot removal, recursion, CAS)
  • Re-check poller.go (tick → backfill → apply)
  • Check feeder / data-source propagation + new tests
  • Build affected packages
  • Post review

Re-reviewed the latest push (62f9ccb4f..8760fd658): envpreConfirmedEnvelope, pcpreconfirmed, removal of receiver nil-checks, NewChain constructor + chain-owned base-state resolution, and removal of UnsafeSnapshot + the BlockHashLag view cap. Build passes (go build ./sync/preconfirmed/... ./clients/feeder/... ./starknet/... ./starknetdata/... ./sync/...). Tests couldn't be executed here (sandbox denies go test), but they use testing/synctest, so the time.Sleep(tickInterval) calls run on virtual time and aren't flaky. No blocking or important issues — not duplicating the two existing nit threads inline.

What I verified on this push

  • UnsafeSnapshot removal resolves nit CI: Automated deployment of docs on GH Pages #1's worst case. The unbounded-recursion path (raw chain length, no head trim) is gone — walkOldestFirst/PreConfirmedStateAt now only ever run against SnapshotForHead views. The doc comment on walkOldestFirst (chain_storage.go:224-229) was also updated and no longer claims a BlockHashLag bound. ✅ Note the cap removal makes the view uncapped by design (SnapshotForHead doc at :263-268 explains far-ahead get_block_hash failures are intentionally left to the execution layer), so recursion depth now equals the live chain length rather than ≤ BlockHashLag. That length is still self-limiting in practice — the poller only builds the chain while atTip (poller.go:103), so it can't grow large while the node is catching up — so this is a nit, not a concern.
  • Chain-owns-base-resolution is consistent under a trimmed view. baseState/PreConfirmedStateAt/PreConfirmedStateBeforeIndexAt each recompute bottom = head.Number − (length−1). SnapshotForHead keeps head at the stored tip and only shortens length, so bottom collapses to wantBottom == head+1 and base opens canonical state at exactly bottom−1. The three independent recomputations agree. ✅
  • NewChain constructor validates non-nil entries and contiguous, ascending block numbers, returning the zero-value ChainReader for the no-arg case — matches how the empty view is treated elsewhere (length == 0 guards). ✅
  • Removed receiver nil-checks are safe. The only nil *ChainReader producer (UnsafeSnapshot) is gone; SnapshotForHead returns a zero value ChainReader{} and the surviving c == nil arms live only on the loaded atomic.Pointer (SnapshotForHead/AdvanceTo/computeUpdate), which is legitimately nil when storage is empty — not on method receivers. ✅
  • tick forward-jump. NoChange/Delta collapse blockNumber to fromBlock (poller.go:133-136), so the jump branch only ever carries a Full update; backfill advances to blockNumber−1 and the final apply lands as extend. Stale baseTxCount is ignored on the Full path (documented :144-150). ✅
  • CAS / single-writer & lifecycle unchanged and still sound — Load→CAS surfaces races as errors, Run recovers next tick, goroutine exits on ctx.Done() with ticker.Stop() deferred. ✅

Open threads

  • rodrodros — direct unit test for PreConfirmedBlockLatest (feeder.go:404): still not added — grep finds the symbol only in feeder.go, not in feeder_test.go, and codecov shows the function at ~54% with the latest path uncovered. A focused test asserting BlockNumber propagates from the envelope (and that "latest" is the requested number) is a cheap win and the one item from that review still outstanding. Author's call, non-blocking. Fix this →
  • rodrodros — naming (env, pc) and receiver nil-checks: addressed. ✅
  • Nit CI: Automated deployment of docs on GH Pages #1 (unbounded recursion): worst case removed with UnsafeSnapshot; residual is the now-uncapped (but self-limiting) SnapshotForHead depth above. (thread)
  • Nit Make Juno docs available at gojuno.xyz #2 (partial-progress re-publish in backfill): still applies — apply Sends each entry as it goes (poller.go:197-198), so a later error leaves earlier entries committed + published and the next tick can re-publish. Correct recovery, but worth documenting the feed as idempotent — and since feed.Send drops under backpressure and SubscribeKeepLast keeps only the latest, the consumer (next PR) must tolerate both duplicates and skipped intermediates and reconstruct from SnapshotForHead rather than treat the feed as gap-free. (thread)

Nice work — the storage abstraction is clean, and removing UnsafeSnapshot tightened the one rough edge from the earlier passes.
· feat/multiple-preconfirmed-blocks

@EgeCaner EgeCaner force-pushed the chore/remove-candidate-txs-and-deprecated-pconf-api branch from 8bd2e59 to dc99137 Compare June 18, 2026 18:27
@EgeCaner EgeCaner force-pushed the feat/multiple-preconfirmed-blocks branch from 8760fd6 to a97fa17 Compare June 18, 2026 18:28
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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


Re-review — multi-block pre_confirmed poller + storage

  • Read changed files (feeder, starknet, sync/preconfirmed)
  • Analyze chain_storage.go (concurrency, recursion, CAS)
  • Analyze poller.go (tick → backfill → apply)
  • Review feeder / data-source propagation + new tests
  • Build affected packages
  • Post review

Re-reviewed the latest push (a97fa17f8 — adds the PreConfirmedBlockLatest endpoint test + restructures fixtures; e220a8201 — removes the BlockHashLag view cap and UnsafeSnapshot). Build passes (go build ./sync/preconfirmed/... ./clients/feeder/... ./starknet/... ./starknetdata/... ./sync/...). Tests couldn't run here (sandbox denies go test), but they use testing/synctest, so the time.Sleep(tickInterval) calls run on virtual time and aren't flaky. No blocking or important issues — the two open nit threads remain the only substantive findings, so I'm not duplicating them inline.

What I verified on this push

  • PreConfirmedBlockLatest direct test added (feeder_test.go:1271) — closes rodrodros's outstanding request. It asserts BlockNumber propagation from the envelope (10936237) across all three discrimination paths: blank-identifier → Full, matching identifier+txCount → Delta, matching identifier with no new txns → NoChange. The test_feeder.go resolver routes "latest" to preconfirmed/latest/... fixtures, so the test exercises the real "latest" query path, not a stub. ✅
  • UnsafeSnapshot / cap removal is clean. The unbounded-recursion worst case (raw chain length on an uncapped unsafe snapshot) is gone — walkOldestFirst/PreConfirmedStateAt now only ever run against SnapshotForHead views, and the walkOldestFirst / SnapshotForHead doc comments (chain_storage.go:224-229, :263-268) were updated to reflect that the view is intentionally uncapped (far-ahead get_block_hash failures are left to the execution layer). Recursion depth now equals live chain length, which is self-limiting since the poller only builds the chain while atTip (poller.go:103). Nit, not a concern. ✅
  • Envelope decoding (pre_confirmed_update.go:80) — block_number is peeked and propagated only when present; the explicit-number endpoint that omits it leaves BlockNumber zero, and the poller's NoChange/Delta switch collapses blockNumber to fromBlock (poller.go:133-136) so a missing wire field never reaches the forward-jump branch. ✅
  • Chain-owns-base-resolutionbaseState/PreConfirmedStateAt/PreConfirmedStateBeforeIndexAt each recompute bottom = head.Number − (length−1); under a SnapshotForHead-trimmed view (shorter length, same head) this collapses to wantBottom == head+1, so base opens canonical state at exactly bottom−1. The three independent recomputations agree. ✅
  • CAS / single-writer & lifecycle unchanged and still sound — ApplyUpdate/AdvanceTo Load→CAS surface races as errors rather than corrupting state, Run recovers on the next tick, goroutine exits on ctx.Done() with ticker.Stop() deferred. ✅

Open threads

  • rodrodros — direct test for PreConfirmedBlockLatest: ✅ addressed this push.
  • rodrodros — naming (envpreConfirmedEnvelope, pcpreconfirmed) and receiver nil-checks: ✅ addressed earlier.
  • Nit CI: Automated deployment of docs on GH Pages #1 (unbounded recursion): worst case removed with UnsafeSnapshot; residual is the now-uncapped but self-limiting SnapshotForHead depth. (thread)
  • Nit Make Juno docs available at gojuno.xyz #2 (partial-progress re-publish in backfill): still applies — apply Sends each entry as it goes (poller.go:197-198), so a later error leaves earlier entries committed + published and the next tick can re-publish. Correct recovery, but since feed.Send drops under backpressure and SubscribeKeepLast keeps only the latest, the consumer (next PR) must tolerate both duplicates and skipped intermediates and reconstruct from SnapshotForHead rather than treat the feed as gap-free — worth a one-line contract note when that consumer lands. (thread)

Nice work — all of rodrodros's review items are addressed, and removing UnsafeSnapshot tightened the one rough edge from the earlier passes. The storage abstraction is clean and the invariants are easy to verify from the doc comments.
· feat/multiple-preconfirmed-blocks

@EgeCaner EgeCaner force-pushed the chore/remove-candidate-txs-and-deprecated-pconf-api branch from dc99137 to 02e0259 Compare June 24, 2026 08:44
@EgeCaner EgeCaner force-pushed the feat/multiple-preconfirmed-blocks branch from a97fa17 to fb605cd Compare June 24, 2026 08:53
@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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


Re-review — multi-block pre_confirmed poller + storage

  • Read changed files & diff vs chore/remove-candidate-txs-and-deprecated-pconf-api
  • Analyze chain_storage.go (concurrency, recursion, CAS)
  • Analyze poller.go (tick → backfill → apply)
  • Review feeder / data-source propagation + tests
  • Build affected packages
  • Post review

Re-reviewed the latest push (fb605cd5dPreConfirmedBlockLatest endpoint test + fixture restructure; 5fb7211c4BlockHashLag view cap + UnsafeSnapshot removal, rebased onto the updated base). Build passes (go build ./sync/preconfirmed/... ./clients/feeder/... ./starknet/... ./starknetdata/... ./sync/...). Tests use testing/synctest, so the time.Sleep(tickInterval) calls run on virtual time and aren't flaky. No blocking or important issues — the two open nit threads remain the only substantive findings, so I'm not duplicating them inline.

What I verified on this push

  • PreConfirmedBlockLatest direct test landed (feeder_test.go:1325) — closes rodrodros's outstanding request. It asserts BlockNumber propagation (10936237) across all three discrimination paths: blank identifier → Full, matching identifier+txCount → Delta, matching identifier with no new txns → NoChange, and routes through the real "latest" query path (not a stub). ✅
  • Envelope decoding (pre_confirmed_update.go:152) — block_number is peeked and propagated only when present; the explicit-number endpoint that omits it leaves BlockNumber zero, and the poller's NoChange/Delta switch collapses blockNumber to fromBlock (poller.go:133-136) so a missing wire field never reaches the forward-jump branch. ✅
  • UnsafeSnapshot / cap removal is clean. The unbounded-recursion worst case is gone — walkOldestFirst/PreConfirmedStateAt now only run against SnapshotForHead views, and the doc comments (chain_storage.go:224-229, :263-268) were updated to reflect the intentionally-uncapped view (far-ahead get_block_hash failures left to the execution layer). Depth now equals live chain length, self-limiting since the poller only builds the chain while atTip (poller.go:103). ✅
  • Chain-owns-base-resolution is consistent under a trimmed view. baseState/PreConfirmedStateAt/PreConfirmedStateBeforeIndexAt each recompute bottom = head.Number − (length−1); under a SnapshotForHead-trimmed view (shorter length, same head) this collapses to wantBottom == head+1, so base opens canonical state at exactly bottom−1. The three independent recomputations agree. ✅
  • tick forward-jump. NoChange/Delta collapse blockNumber to fromBlock, so the jump branch only ever carries a Full update; backfill advances to blockNumber−1 and the final apply lands as extend. Stale baseTxCount is ignored on the Full path (documented :144-150). ✅
  • CAS / single-writer & lifecycle. ApplyUpdate/AdvanceTo Load→CAS surface races as errors rather than corrupting state; Run recovers on the next tick; goroutine exits on ctx.Done() with ticker.Stop() deferred — no leak. ✅
  • NewChain constructor validates non-nil entries and contiguous ascending block numbers, returning the zero-value ChainReader for the no-arg case — matches the length == 0 empty-view treatment elsewhere. ✅

Open threads

  • rodrodros — direct test for PreConfirmedBlockLatest: ✅ addressed this push.
  • rodrodros — naming (envpreConfirmedEnvelope, pcpreconfirmed) + receiver nil-checks: ✅ addressed earlier.
  • Nit CI: Automated deployment of docs on GH Pages #1 (unbounded recursion): worst case removed with UnsafeSnapshot; residual is the now-uncapped but self-limiting SnapshotForHead depth. Non-blocking. (thread)
  • Nit Make Juno docs available at gojuno.xyz #2 (partial-progress re-publish in backfill): still applies — apply Sends each entry as it goes (poller.go:197-198), so a later error leaves earlier entries committed + published and the next tick can re-publish. Correct recovery, but since feed.Send drops under backpressure and SubscribeKeepLast keeps only the latest, the consumer (next PR) must tolerate both duplicates and skipped intermediates and reconstruct from SnapshotForHead rather than treat the feed as gap-free — worth a one-line contract note when that consumer lands. (thread)

Nice work — all of rodrodros's review items are addressed, the storage abstraction is clean, and the invariants are easy to verify from the doc comments.
· feat/multiple-preconfirmed-blocks

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

Overall, LGTM.
I will not approve it since it's not supposed to be merged as it is (as you said, the changes must be integrated). Also, there are some points missing that I will only be able to properly review in the full PR integrating it

I left a few comments

Comment on lines 81 to 85
var peek struct {
Changed *bool `json:"changed"`
Timestamp *uint64 `json:"timestamp"`
Changed *bool `json:"changed"`
Timestamp *uint64 `json:"timestamp"`
BlockNumber *uint64 `json:"block_number"`
}

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.

Suggestion: we're unmarshalling the entire block twice here only to know the update type, but if we pay attention, all fields from Delta and all the fields from peek (except 'blockNumber') are present in the Full response. WDYT of changing peek to be PreConfirmedBlock + BlockNumber, so that we unmarshal once and populate eveything with it?

Suggested change
var peek struct {
Changed *bool `json:"changed"`
Timestamp *uint64 `json:"timestamp"`
Changed *bool `json:"changed"`
Timestamp *uint64 `json:"timestamp"`
BlockNumber *uint64 `json:"block_number"`
}
var peek struct {
PreConfirmedBlock
BlockNumber *uint64 `json:"block_number"`
}

Comment on lines +180 to +182
if target == nil {
return nil, pending.ErrPreConfirmedNotFound
}

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 check is unecessary, correct? We already know that c.lenth is > 0, and that the block number is between bottom and head block number; target should never be nil. The only case would be if we stored a nil preconfirmed in the chain, but that would be a bug, right?

Comment thread sync/preconfirmed/chain_storage.go Outdated
Comment on lines +275 to +279
want := int(viewTip - wantBottom + 1)
if viewHead == c.head && want == c.length {
return *c
}
return ChainReader{head: viewHead, length: want}

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.

WDYT? We are returning by value anyway; same bytes size. Measuring both, I vote on remove this check to simplify a little bit the logic

Suggested change
want := int(viewTip - wantBottom + 1)
if viewHead == c.head && want == c.length {
return *c
}
return ChainReader{head: viewHead, length: want}
return ChainReader{head: viewHead, length: int(viewTip - wantBottom + 1)}

Comment on lines +38 to +40
require.Equal(t, "PRE_CONFIRMED", full.Status)
require.NotZero(t, full.Timestamp)
require.NotNil(t, full.SequencerAddress)

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.

Nitpick: here and in the other parts, we could use assert instead of required

require.True(t, ok, "expected PreConfirmedBlock, got %T", env.Update)
require.Equal(t, "0x1cbe25d9", full.BlockIdentifier)
require.Equal(t, "PRE_CONFIRMED", full.Status)
require.NotZero(t, full.Timestamp)

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.

Not related to your changes, but could you please add the timestamp validation for the PreConfirmedBlock.validate() method? I forgot to add in my PR.

Also, maybe we could use the PreConfirmedUpdateEnvelope.Validate() method here instead of all these manual assertions. Do as you prefer

type PreConfirmedUpdateEnvelope struct {
Update PreConfirmedUpdate
Update PreConfirmedUpdate
BlockNumber uint64

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 new Validate() feat was implemented in main and included on this PR, but this PR introduces the new BlockNumber field, which is not being validated. However, it's hard to do so since the same type is used for both 'latest' and 'blockNumber' queries on the feeder 'get_preconfirmed_endpoint'. WDYT about adding a new type specific for the 'latest' call, use it in the feeder endpoint validation, and before returning it, cast it to the PreConfirmedUpdateEnvelope type?
This is just one way of doing it, but the main point is that we should validate this as well somehow

Base automatically changed from chore/remove-candidate-txs-and-deprecated-pconf-api to main June 25, 2026 17:10
@rodrodros rodrodros force-pushed the feat/multiple-preconfirmed-blocks branch from fb605cd to 30ab42e Compare June 26, 2026 08:17
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rodrodros's task in 1m 10s —— View job


Re-review — multi-block pre_confirmed poller + storage

  • Read changed files (feeder, starknet, sync/preconfirmed)
  • Re-trace chain_storage.go (concurrency, recursion, CAS)
  • Re-trace poller.go (tick → backfill → apply)
  • Review the open threads from @thiagodeev's 2026-06-25 pass
  • Post review

Re-reviewed the current tip. No blocking or important issues — the immutable single-writer / lock-free-reader model still holds and I found no new correctness bug, race, nil-deref, or leak. The only outstanding items are @thiagodeev's review points from 2026-06-25; my read on each below.

@thiagodeev's open points (all non-blocking, author's call)

  1. pre_confirmed_update.go:152 — unmarshal once instead of twice. Valid simplification. The Full path currently unmarshals data into peek and then again into PreConfirmedBlock. Embedding PreConfirmedBlock in peek collapses the Full case to a single decode (the Delta/NoChange cases still need only the peeked fields). Worth doing, but it does change the discrimination shape slightly — peek would then always allocate the full block — so it trades a redundant decode for a larger peek struct. Either is fine.

  2. pre_confirmed_update.go:149 / Validate()BlockNumber isn't validated. This is the most substantive of the five and worth a deliberate decision rather than a silent skip. Today Validate() ignores BlockNumber, so a latest response that omits block_number decodes to BlockNumber == 0. I traced the blast radius: in poller.tick, NoChange/Delta collapse blockNumber to fromBlock, and a Full with BlockNumber == 0 fails blockNumber > fromBlock, so the final apply calls computeUpdate with blockNumber == 0, which a non-empty chain rejects as "apply target 0 below bottom" — logged + retried, not corruption. So it degrades safely, but a missing wire field silently becoming "no forward progress" is exactly the kind of thing a validator should catch. @thiagodeev's "separate latest type, validate, then cast" is a clean way; a lighter option is a ValidateLatest() that additionally requires BlockNumber != 0. Recommend picking one before the consumer-wiring PR.

  3. chain_storage.go:195if target == nil in PreConfirmedStateBeforeIndexAt. Agreed it's currently unreachable: the preceding [bottom, mostRecent] range check guarantees OldestFirst yields the target. It's dead defensive code given the single-writer invariant, but it's cheap insurance against a future caller that skips the range check — author's call whether to drop it or keep it as a guard.

  4. chain_storage.go:288-291if want == c.length { return *c }. Returning ChainReader{head: c.head, length: want} unconditionally is the same byte size and simpler. The only thing the fast-path buys is reusing the exact loaded struct, which is immaterial since it's a value copy either way. Fine to simplify.

  5. pre_confirmed_update_test.goassert over require, add timestamp validation, reuse Validate(). All reasonable test-hygiene improvements; no correctness impact.

Previously-filed nits (unchanged, both still non-blocking)

  • Nit CI: Automated deployment of docs on GH Pages #1 (recursion depth): worst case was already removed with UnsafeSnapshot; residual SnapshotForHead depth equals live chain length, self-limiting since the poller only extends the chain while atTip (poller.go:103). (thread)
  • Nit Make Juno docs available at gojuno.xyz #2 (partial-progress re-publish in backfill): apply Sends each entry as it goes (poller.go:197-198), so a later error leaves earlier entries committed + published and the next tick can re-publish. Correct recovery, but since feed.Send drops under backpressure and SubscribeKeepLast keeps only the latest, the consumer (next PR) must tolerate both duplicates and skipped intermediates and reconstruct from SnapshotForHead rather than treat the feed as gap-free. Worth a one-line contract note when that consumer lands. (thread)

Nice work — the storage abstraction is clean and the doc comments make the invariants easy to verify. Of the open items, #2 (validate BlockNumber) is the one I'd most want resolved; the rest are cosmetic.
· feat/multiple-preconfirmed-blocks

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