Skip to content

Add offset support to MemoryFreezer#1965

Open
LarryArnault45 wants to merge 8 commits into
0xPolygon:developfrom
LarryArnault45:memory-freezer-offset
Open

Add offset support to MemoryFreezer#1965
LarryArnault45 wants to merge 8 commits into
0xPolygon:developfrom
LarryArnault45:memory-freezer-offset

Conversation

@LarryArnault45
Copy link
Copy Markdown
Contributor

MemoryFreezer was missing offset handling, causing issues when used with ancient database pruning.

@github-actions
Copy link
Copy Markdown

This PR is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@sonarqubecloud
Copy link
Copy Markdown

@LarryArnault45
Copy link
Copy Markdown
Contributor Author

Hi @pratikspatil024, I fixed the CI issues. Could you approve the workflow runs when you get a chance? Thanks!

@github-actions
Copy link
Copy Markdown

This PR is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Feb 22, 2026
@pratikspatil024
Copy link
Copy Markdown
Member

@LarryArnault45 - could you push an empty commit?

@github-actions github-actions Bot removed the Stale label Mar 3, 2026
@marcello33 marcello33 requested a review from Copilot March 18, 2026 19:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds pruning-offset support to the in-memory ancient store (MemoryFreezer) so it can represent an ancient DB that starts at a non-zero block number (e.g., after pruning older ancients), aligning behavior with the file-based freezer’s offset concept.

Changes:

  • Extend NewMemoryFreezer to accept an offset and initialize internal counters accordingly.
  • Apply the offset in memory freezer write sequencing and in Ancient, TruncateHead, and TruncateTail.
  • Update call sites and tests to use the new NewMemoryFreezer signature.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
core/rawdb/freezer_memory.go Adds offset parameter + uses it in read/write/truncation paths for the in-memory freezer.
core/rawdb/freezer_memory_test.go Updates tests to the new constructor signature.
core/rawdb/chain_freezer.go Passes the pruning offset into the in-memory freezer when running without a datadir.
core/rawdb/ancient_scheme.go Updates state freezer’s in-memory path to pass offset 0.
Comments suppressed due to low confidence (1)

core/rawdb/freezer_memory.go:268

  • Ancient now subtracts f.offset from number without guarding number < offset. With unsigned arithmetic this underflows and relies on downstream bounds checks to fail; it also makes the error cause less clear. Consider explicitly returning errOutOfBounds (or a dedicated error) when number < f.offset.Load(), and ensure AncientRange applies the same offset semantics for consistency (currently it passes start through unchanged).
func (f *MemoryFreezer) Ancient(kind string, number uint64) ([]byte, error) {
	f.lock.RLock()
	defer f.lock.RUnlock()

	t := f.tables[kind]
	if t == nil {
		return nil, errUnknownTable
	}
	data, err := t.retrieve(number-f.offset.Load(), 1, 0)
	if err != nil {
		return nil, err
	}
	return data[0], nil
}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

return NewMemoryFreezer(false, tables)
return NewMemoryFreezer(false, 0, tables)
})
}
Comment on lines 217 to 236
// NewMemoryFreezer initializes an in-memory freezer instance.
func NewMemoryFreezer(readonly bool, tableName map[string]freezerTableConfig) *MemoryFreezer {
func NewMemoryFreezer(readonly bool, offset uint64, tableName map[string]freezerTableConfig) *MemoryFreezer {
tables := make(map[string]*memoryTable)
for name, cfg := range tableName {
tables[name] = newMemoryTable(name, cfg)
}
return &MemoryFreezer{
freezer := &MemoryFreezer{
writeBatch: newMemoryBatch(),
readonly: readonly,
tables: tables,
}

freezer.offset.Store(offset)

// Some blocks in ancientDB may have already been frozen and been pruned, so adding the offset to
// represent the absolute number of blocks already frozen.
freezer.items += offset

return freezer
}
Comment on lines 370 to 376
return old, nil
}
for _, table := range f.tables {
if err := table.truncateHead(items); err != nil {
if err := table.truncateHead(items - f.offset.Load()); err != nil {
return 0, err
}
}
Comment on lines 394 to 400
}
for _, table := range f.tables {
if table.config.prunable {
if err := table.truncateTail(tail); err != nil {
if err := table.truncateTail(tail - f.offset.Load()); err != nil {
return 0, err
}
}
@LarryArnault45
Copy link
Copy Markdown
Contributor Author

@pratikspatil024 , did it, sir.

@github-actions
Copy link
Copy Markdown

This PR is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Apr 11, 2026
@pratikspatil024
Copy link
Copy Markdown
Member

@LarryArnault45 - could you fix the build?

@LarryArnault45
Copy link
Copy Markdown
Contributor Author

@pratikspatil024 WIll do.

@sonarqubecloud
Copy link
Copy Markdown

@github-actions github-actions Bot removed the Stale label Apr 14, 2026
@pratikspatil024
Copy link
Copy Markdown
Member

Hi @LarryArnault45 - thanks for the fixes. The CI looks good now, but now there are some additional changes in github workflows, docker files, and go files which are not related.
Could you remove them?
Or maybe merge the latest develop

@LarryArnault45
Copy link
Copy Markdown
Contributor Author

@pratikspatil024 Done.

@pratikspatil024
Copy link
Copy Markdown
Member

@claude review once

@claude
Copy link
Copy Markdown

claude Bot commented May 15, 2026

Claude finished @pratikspatil024's task in 4m 18s —— View job


Review of PR #1965: Add offset support to MemoryFreezer

  • Gather context: read PR diff and changed files
  • Review freezer_memory.go changes
  • Review freezer_memory_test.go changes
  • Review chain_freezer.go and ancient_scheme.go changes
  • Post review findings

Summary

This PR adds pruning-offset support to the in-memory freezer (MemoryFreezer), aligning it with the file-based Freezer which already supports offsets. The approach is sound — the MemoryFreezer stores items in memoryTable using relative indices (0-based), while exposing absolute block numbers to callers (relative + offset). The PR correctly adjusts Ancient, AncientRange, TruncateHead, TruncateTail, ModifyAncients, Reset, and memoryBatch.reset to account for this.

Overall this is a clean, well-tested change. One bug, one minor issue, and a few nits below.


Bug: AncientBytes missing offset adjustment

Severity: Medium (low impact today, but a correctness defect)

freezer_memory.go:469-490AncientBytes passes id directly to table.retrieve(id, 1, 0) without subtracting f.offset. This is inconsistent with Ancient() (line 273) and AncientRange() (line 298) which both subtract offset.

// Current (buggy):
entries, err := table.retrieve(id, 1, 0)

// Should be:
offset := f.offset.Load()
if id < offset {
    return nil, errOutOfBounds
}
entries, err := table.retrieve(id-offset, 1, 0)

With a non-zero offset, AncientBytes will pass an absolute block number to table.retrieve which expects a relative index — this will return wrong data or errOutOfBounds. Currently low-impact because AncientBytes is only called on state/trienode freezers (always offset=0), but it's a latent bug if anyone calls it on a chain freezer.

Note: The file-based Freezer.AncientBytes has the same issue (no offset subtraction at line 218), but the file-based freezerTable.RetrieveBytes operates on its own internal index which may handle this differently. The memory table's retrieve definitely uses 0-based relative indexing, so the offset subtraction is needed here.

Fix this →


Minor: Reset does not reset tail's offset relationship

freezer_memory.go:457:

f.items, f.tail = f.offset.Load(), 0

After Reset, f.items is set to offset (correct — the absolute head goes back to the offset), but f.tail is set to 0. Since f.tail stores an absolute tail position (as set by TruncateTail which stores tail directly), resetting it to 0 means the tail is below the offset. This is inconsistent — TruncateTail would reject tail < offset with errTruncateBelowOffset, yet Reset creates that exact state.

This likely doesn't cause a runtime issue (tail=0 < offset means TruncateTail would just update it upward), but for consistency, consider setting f.tail = f.offset.Load() to match the semantics.


What looks good

  1. memoryBatch.reset (line 153-154): Correctly computes table.items + freezer.offset.Load() so that Append/AppendRaw callers can use absolute block numbers. The test at line 69-73 verifies that appending at block 0 (below offset 10) is rejected.

  2. Bounds checks in Ancient/AncientRange (lines 270-272, 295-297): Properly guards against number < offset before the subtraction, preventing unsigned underflow. This addresses the Copilot review concern.

  3. TruncateHead/TruncateTail guards (lines 389-391, 416-418): Explicit errTruncateBelowOffset checks prevent underflow and provide clear error messages. Good improvement over the file-based freezer which lacks these guards.

  4. ModifyAncients rollback (line 355): table.truncateHead(old - offset) correctly converts the absolute old to a relative table index. The file-based freezer handles this differently (passes absolute values to its table which has different internal semantics), but the approach here is correct for memoryTable.

  5. Tests are thoroughTestMemoryFreezerOffset covers: initial state, append sequencing, reads with absolute numbers, below-offset rejection, both truncation directions with boundary checks, and post-truncation reads. TestMemoryFreezerResetKeepsOffset verifies reset preserves offset and accepts appends at the offset boundary.

  6. Call-site updateschain_freezer.go:72 correctly passes the offset parameter through. ancient_scheme.go correctly passes 0 for state/trienode freezers which don't use pruning offsets.


Nit

The comment at line 233-234 could be tightened:

// Some blocks in ancientDB may have already been frozen and been pruned, so adding the offset to
// represent the absolute number of blocks already frozen.

Consider: // items tracks the absolute count (actual items + pruned offset)


Verdict

The core logic is correct and well-tested. The AncientBytes bug should be fixed before merge. The Reset tail inconsistency is minor but worth addressing for correctness.

Comment on lines 266 to 276
if t == nil {
return nil, errUnknownTable
}
data, err := t.retrieve(number, 1, 0)
offset := f.offset.Load()
if number < offset {
return nil, errOutOfBounds
}
data, err := t.retrieve(number-offset, 1, 0)
if err != nil {
return nil, err
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 MemoryFreezer.AncientBytes (core/rawdb/freezer_memory.go:467-490) was not updated to be offset-aware, while this PR adds offset translation to Ancient and AncientRange. With offset > 0, AncientBytes silently disagrees with Ancient/AncientRange: e.g. AncientBytes("test", 12, 0, n) on a freezer with offset=10 and items at absolute ids 10..12 hits the t.items<=start guard and returns errOutOfBounds, while AncientBytes("test", 2, 0, n) — below the absolute start — would silently return data for what is actually item 12. Fix: subtract f.offset.Load() from id with the same underflow guard the PR adds for Ancient/AncientRange/TruncateHead/TruncateTail, and add a TestMemoryFreezerOffset case for AncientBytes.

Extended reasoning...

The bug

This PR makes MemoryFreezer offset-aware, updating Ancient, AncientRange, TruncateHead, TruncateTail, ModifyAncients, Reset and the write-batch initializer to translate between absolute item numbers and table-relative indices via f.offset.Load(). However, the parallel reader MemoryFreezer.AncientBytes (core/rawdb/freezer_memory.go:467-490) — which is not in the diff — was not updated. It still calls table.retrieve(id, 1, 0) with the absolute id.

After this PR, AncientBytes is the only AncientReaderOp method left non-offset-aware, so a MemoryFreezer constructed with offset > 0 produces inconsistent results across its reader methods.

Step-by-step proof

Construct f := NewMemoryFreezer(false, 10, {"test": …}) and append three items at absolute ids 10, 11, 12 (matching the new TestMemoryFreezerOffset). After commit, the underlying memoryTable has t.items = 3 and t.offset = 0 (the memoryTable.items field is incremented only by commit and starts at 0 — unlike the freezer-level f.items which now includes the offset).

  1. f.Ancient("test", 12) → updated path: number=12, offset=10, calls t.retrieve(12-10, 1, 0) = t.retrieve(2, 1, 0) → returns "twelve". ✓
  2. f.AncientBytes("test", 12, 0, n) → unchanged path: calls t.retrieve(12, 1, 0) → trips the t.items <= start guard (3 <= 12) → returns errOutOfBounds. ✗ (disagrees with Ancient)
  3. f.AncientBytes("test", 2, 0, n) (an id below the freezer's absolute start of 10) → calls t.retrieve(2, 1, 0) → silently returns "twelve" (the entry at relative index 2, i.e. absolute id 12). ✗ (worse: wrong data, not even an error)

Why existing code does not prevent it

The new TestMemoryFreezerOffset exercises Ancient, AncientRange, TruncateHead, TruncateTail, and Reset against offset=10, but does not call AncientBytes, so this gap is not caught. chainFreezer.AncientBytes (core/rawdb/chain_freezer.go:451) is a direct pass-through, and newChainFreezer now wires a non-zero offset into NewMemoryFreezer for the in-memory chain freezer case, so the inconsistency is reachable through the public ethdb.AncientStore interface even if no current caller of AncientBytes happens to use an offset != 0 today (state/trienode constructors still pass 0; chain reads currently route through Ancient, not AncientBytes).

Impact

Latent today (no current production caller triggers it), but a real correctness/consistency bug introduced by this PR's offset refactor. Any future caller — or any future code path that routes chain-table reads through AncientBytes while the chain freezer is configured with a pruning offset — would either silently read the wrong item (when id < offset) or get a spurious errOutOfBounds (when id >= offset). The contract that all reader methods agree on absolute ids is broken on the same struct.

Suggested fix

Apply the same translation and underflow guard already used for Ancient and AncientRange:

func (f *MemoryFreezer) AncientBytes(kind string, id, offset, length uint64) ([]byte, error) {
    f.lock.RLock()
    defer f.lock.RUnlock()

    table := f.tables[kind]
    if table == nil {
        return nil, errUnknownTable
    }
    tableOffset := f.offset.Load()
    if id < tableOffset {
        return nil, errOutOfBounds
    }
    entries, err := table.retrieve(id-tableOffset, 1, 0)
    // … rest unchanged
}

And extend TestMemoryFreezerOffset with AncientBytes("test", 12, …) succeeding and AncientBytes("test", 9, …) returning errOutOfBounds, mirroring the existing Ancient/AncientRange assertions.

@pratikspatil024
Copy link
Copy Markdown
Member

Thanks @LarryArnault45 - I would like to have the 2 issues raised by claude addressed in the PR. Could you do that?

@LarryArnault45
Copy link
Copy Markdown
Contributor Author

@pratikspatil024 sure, will do.

@sonarqubecloud
Copy link
Copy Markdown

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