Skip to content

feat(storage): add drain_above to ColdStorage + UnifiedStorage#38

Open
prestwich wants to merge 2 commits intomainfrom
james/eng-1978
Open

feat(storage): add drain_above to ColdStorage + UnifiedStorage#38
prestwich wants to merge 2 commits intomainfrom
james/eng-1978

Conversation

@prestwich
Copy link
Member

@prestwich prestwich commented Mar 9, 2026

Summary

  • Adds drain_above as a native ColdStorage trait method with a default impl that composes get_latest_block + get_receipts_in_block + truncate_above
  • Overrides drain_above atomically in each backend:
    • MemColdBackend: holds write lock for entire operation
    • MdbxColdBackend: single read-write transaction (read receipts + delete in one commit)
    • SqlColdBackend: sequential reads + truncate (atomic via task runner's sequential write processing)
  • Adds DrainAbove variant to ColdWriteRequest and drain_above method on ColdStorageHandle
  • Simplifies UnifiedStorage::drain_above to use the new atomic cold method instead of N separate reads + dispatch_truncate
  • Adds DrainedBlock struct with header and receipts fields
  • Adds conformance test (test_drain_above) and 3 integration tests
  • Bumps workspace version 0.6.40.6.5

Resolves ENG-1978. Unblocks ENG-1968 (ChainEvent::Reorg notifications).

Test plan

  • Clippy clean (--all-features and --no-default-features) for signet-cold, signet-cold-mdbx, signet-cold-sql, signet-storage
  • cargo +nightly fmt
  • cargo t -p signet-cold — mem conformance passes (includes test_drain_above)
  • cargo t -p signet-cold-mdbx — MDBX conformance passes
  • cargo t -p signet-cold-sql --all-features — SQLite conformance passes
  • cargo t -p signet-storage — 5 tests pass including 3 new drain_above integration tests

🤖 Generated with Claude Code

Adds `DrainedBlock` type and `drain_above` method that reads removed
block headers and receipts before unwinding, enabling reorg notifications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@prestwich prestwich requested a review from Evalir March 9, 2026 17:36
Add `drain_above` as a native `ColdStorage` trait method that atomically
reads receipts and truncates in one operation. Each backend overrides
with its own atomic version:

- MemColdBackend: holds write lock for entire operation
- MdbxColdBackend: single read-write transaction
- SqlColdBackend: sequential reads + truncate (atomic via task runner)

Update `UnifiedStorage::drain_above` to use the new atomic cold method
instead of N separate reads + dispatch_truncate. Add conformance test
and integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@prestwich prestwich changed the title feat(storage): add drain_above to UnifiedStorage feat(storage): add to + Mar 9, 2026
@prestwich prestwich changed the title feat(storage): add to + feat(storage): add drain_above to ColdStorage + UnifiedStorage Mar 9, 2026
@prestwich prestwich requested a review from Evalir March 9, 2026 18:12
@prestwich
Copy link
Member Author

@Evalir i have made this more complicated to avoid a class of concurrency issues

Copy link
Contributor

@Fraser999 Fraser999 left a comment

Choose a reason for hiding this comment

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

Just blocking due to the potential TOCTOU issue. Mostly nitpicks otherwise.

Ok(())
}

fn drain_above_inner(
Copy link
Contributor

Choose a reason for hiding this comment

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

This is very similar to truncate_above_inner just above here. Could we extract the shared logic, or get rid of truncate_above_inner, or have truncate_above_inner delegate to drain_above_inner discarding the return value?

Comment on lines +1400 to +1415
async fn drain_above(&self, block: BlockNumber) -> ColdResult<Vec<Vec<ColdReceipt>>> {
// Read receipts then truncate, all within a single SQL transaction.
// We use the default trait logic against self (which will use the pool
// for reads), then truncate. For true atomicity under concurrent
// access the caller should ensure exclusive write access via the task
// runner, which processes writes sequentially.
let latest = self.get_latest_block().await?;
let mut all_receipts = Vec::new();
if let Some(latest) = latest {
for n in (block + 1)..=latest {
all_receipts.push(self.get_receipts_in_block(n).await?);
}
}
self.truncate_above(block).await?;
Ok(all_receipts)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This is the same as the default impl it's overriding.

Comment on lines +239 to +242
/// 1. Reads headers from hot storage (sync)
/// 2. Reads receipts from cold storage (async, best-effort)
/// 3. Unwinds hot storage (sync)
/// 4. Dispatches truncate to cold storage (non-blocking)
Copy link
Contributor

Choose a reason for hiding this comment

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

This doesn't really seem to match the impl now - looks like it was an old comment from before drain_above existed maybe?

/// [`Cold`]: crate::StorageError::Cold
pub async fn drain_above(&self, block: BlockNumber) -> StorageResult<Vec<DrainedBlock>> {
// 1. Read headers above `block` from hot storage
let reader = self.reader()?;
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we just instantiate and use the writer here for reading too rather than creating a reader then later a writer, as that seems to have a TOCTOU race condition? I.e. more blocks could be added while we're working with what has then become a stale reader.


// Verify hot tip is now block 0
assert_eq!(hot.reader().unwrap().get_chain_tip().unwrap().unwrap().0, 0);

Copy link
Contributor

Choose a reason for hiding this comment

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

We could check the cold storage got updated too:

Suggested change
assert_eq!(storage.cold().get_latest_block().await.unwrap().unwrap(), 0);


// Verify hot tip still 1
assert_eq!(hot.reader().unwrap().get_chain_tip().unwrap().unwrap().0, 1);

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
assert_eq!(storage.cold().get_latest_block().await.unwrap().unwrap(), 1);

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