Skip to content

feat(v2): EpochStakes parsing & zero-copy SnapshotMetadata - #1702

Open
kprotty wants to merge 13 commits into
mainfrom
king/snapshot-epoch-stakes
Open

feat(v2): EpochStakes parsing & zero-copy SnapshotMetadata#1702
kprotty wants to merge 13 commits into
mainfrom
king/snapshot-epoch-stakes

Conversation

@kprotty

@kprotty kprotty commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Add BankFields.stakes and ExtraFields.VersionedEpochStakes parsing to snapshot.zig. Also simplifies how that parsed data is communicated to replay.

  • RuntimeMetadata -> SnapshotMetadata (holds FBA memory, shared by accounts_db & replay)
  • SnapshotIter parses Manifest & StatusCache directly into it
  • Manifest/StatusCache hold zero-copy offsets+lens into SnapshotMetadata's FBA memory

@kprotty kprotty self-assigned this Jul 9, 2026
@github-project-automation github-project-automation Bot moved this to 🏗 In progress in Sig Jul 9, 2026
@kprotty kprotty changed the title feat(v2): send EpochStakes to replay feat(v2): send VersionedEpochStakes to replay Jul 14, 2026
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.72043% with 34 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
v2/lib/accounts_db/rooted.zig 0.00% 23 Missing ⚠️
v2/lib/snapshot.zig 73.33% 4 Missing ⚠️
v2/init/main.zig 0.00% 3 Missing ⚠️
v2/lib/solana/snapshot.zig 98.21% 2 Missing ⚠️
v2/services/accounts_db.zig 0.00% 2 Missing ⚠️

❌ Your patch check has failed because the patch coverage (81.72%) is below the target coverage (85.00%). You can increase the patch coverage or adjust the target coverage.

Files with missing lines Coverage Δ
v2/lib/accounts_db.zig 0.00% <ø> (-70.59%) ⬇️
v2/lib/ipc/ring.zig 94.40% <100.00%> (-3.74%) ⬇️
v2/lib/solana/epoch_schedule.zig 98.88% <ø> (ø)
v2/lib/solana/inflation.zig 100.00% <100.00%> (ø)
v2/services/replay.zig 37.56% <100.00%> (-1.03%) ⬇️
v2/lib/solana/snapshot.zig 96.11% <98.21%> (+0.34%) ⬆️
v2/services/accounts_db.zig 0.00% <0.00%> (ø)
v2/init/main.zig 0.00% <0.00%> (ø)
v2/lib/snapshot.zig 39.28% <73.33%> (+39.28%) ⬆️
v2/lib/accounts_db/rooted.zig 0.00% <0.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@InKryption
InKryption self-requested a review July 20, 2026 16:18
kprotty added 2 commits July 20, 2026 13:42
moves away from RuntimeMetadata being delta changes
of already decoded snapshot metadata. Instead, it stores
the snapshot fba & parsed Manifest/StatusCache in a
region directly (SnapshotMetadata) and shares that with
accounts_db and replay. Rooted writes that region to
a fixed sized section in the disk after the journal for
persistence
@kprotty kprotty changed the title feat(v2): send VersionedEpochStakes to replay feat(v2): EpochStakes parsing & zero-copy SnapshotMetadata Jul 21, 2026
@kprotty
kprotty marked this pull request as ready for review July 23, 2026 12:53

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

Rooted.loadSnapshot / loadExisting persist the wrong 15 bytes

The blob write assumes [Manifest][StatusCache][FBA memory] are contiguous in SnapshotMetadata, but the extern-struct layout has 15 bytes between &status_cache and &memory (7 bytes of padding + the 8-byte memory_len field). Verified with zig 0.15.2 on this branch:

offsetOf(manifest)     =    8
offsetOf(status_cache) = 9856
offsetOf(memory_len)   = 9864
offsetOf(memory)       = 9872
hdr_size = @sizeOf(Manifest) + @sizeOf(StatusCache) = 9849
distance &manifest → &memory                        = 9864   // gap = 15

Consequences of writing unpadded = hdr_size + memory_len bytes from &manifest and reading them back the same way:

  1. Bytes intended as FBA [0..15) are actually 7 bytes of struct padding + the memory_len field. On reload memory_len is silently overwritten with the write-time value.
  2. FBA bytes [memory_len-15 .. memory_len) are never written. On reload they come back mmap-zeroed. Any RelativeSlice payload that landed there reads zeros.
  3. The disk_bytes > total_capacity guard uses the same wrong formula, so it also rejects some valid loads with 15 bytes to spare.

Fix by writing/reading the two segments separately, and add a comptime assert so this can't drift again:

comptime std.debug.assert(
    @offsetOf(SnapshotMetadata, "memory") - @offsetOf(SnapshotMetadata, "manifest")
        == @sizeOf(Manifest) + @sizeOf(StatusCache),
);

That assert fails on today's layout (9864 != 9849), which is exactly the bug. The existing unit test never round-trips through the rooted DB so CI is green today — worth adding a small loadSnapshot → loadExisting roundtrip that checks a RelativeSlice read after reload.

Copilot AI 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.

Pull request overview

This PR updates the v2 snapshot ingestion pipeline to parse additional snapshot metadata (EpochStakes / stake-related fields) and to pass snapshot-derived metadata to replay via a new zero-copy SnapshotMetadata shared-memory blob, replacing the prior RuntimeMetadata ring-buffer based handoff.

Changes:

  • Introduces lib.snapshot.SnapshotMetadata (Manifest + StatusCache + trailing FBA memory) and rewires accounts_db → replay to use it as the readiness barrier and metadata carrier.
  • Extends solana/snapshot.zig parsing to include BankFields stake-related data and ExtraFields.versioned_epoch_stakes, and makes Manifest/StatusCache extern + relative-offset based.
  • Persists the metadata blob in the rooted DB journal area and simplifies replay bootstrap to read blockhashes/block_id directly from the Manifest.

Reviewed changes

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

Show a summary per file
File Description
v2/tests/replay/main.zig Updates replay test harness to allocate/init SnapshotMetadata with a trailing FBA region.
v2/services/replay.zig Switches bootstrap input from RuntimeMetadata to SnapshotMetadata and reads blockhashes from Manifest.
v2/services/accounts_db.zig Removes SnapshotIter usage at service layer; delegates snapshot parsing to Rooted using SnapshotMetadata.
v2/lib/solana/snapshot.zig Adds RelativeSlice/RelativeOffset, makes metadata structs extern, and adds stake + versioned epoch stakes parsing.
v2/lib/solana/inflation.zig Adds Inflation model with extern pow for consensus-sensitive calculations.
v2/lib/solana/epoch_schedule.zig Adjusts EpochSchedule layout/field alignment for snapshot deserialization.
v2/lib/solana.zig Exposes Inflation from the solana module.
v2/lib/snapshot.zig Adds SnapshotMetadata shared-memory container and slot readiness barrier.
v2/lib/ipc/ring.zig Adds nextBlocking() helper to ring iterator API.
v2/lib/accounts_db/rooted.zig Persists/restores metadata blob in rooted DB and rewires snapshot load path to populate SnapshotMetadata.
v2/lib/accounts_db.zig Removes RuntimeMetadata definition (replaced by SnapshotMetadata).
v2/init/services.zig Rewires service specs to pass *SnapshotMetadata instead of *RuntimeMetadata.
v2/init/main.zig Allocates SnapshotMetadata region sized for trailing FBA; adds config knob for metadata memory.
v2/config/example.zon Adds .metadata memory sizing for accounts_db.
v2/build.zig Sets code_model = .medium for accounts_db service module.
Comments suppressed due to low confidence (2)

v2/lib/solana/snapshot.zig:43

  • RelativeSlice.sliceConst uses self.len (a u32) directly as a slice bound. Cast to usize explicitly for correctness/clarity.
        pub fn sliceConst(self: Self, base: [*]const u8) []const T {
            const raw = base + self.offset;
            std.debug.assert(@intFromPtr(raw) % @alignOf(T) == 0);
            const ptr: [*]const T = @ptrCast(@alignCast(raw));
            return ptr[0..self.len];
        }

v2/lib/solana/snapshot.zig:61

  • RelativeOffset.from has the same underflow risk as RelativeSlice.fromSlice: it subtracts pointers before asserting ptr >= base. Add an order assertion before subtraction to prevent bogus non-zero offsets.
        pub fn from(base: [*]const u8, ptr: *const T) Self {
            const o = @intFromPtr(ptr) - @intFromPtr(base);
            std.debug.assert(o != 0);
            std.debug.assert(o <= std.math.maxInt(u32));
            return .{ .offset = @intCast(o) };
        }

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

Comment thread v2/lib/solana/snapshot.zig
Comment thread v2/lib/solana/snapshot.zig
Comment on lines +47 to +49
/// A single-pointer variant of `RelativeSlice`. `offset == 0` is the null sentinel;
/// callers must guarantee no valid allocation lands at offset 0 (see `Rooted.loadSnapshot`
/// which reserves the first byte of its FBA for exactly this purpose).
Comment thread v2/lib/solana/snapshot.zig
Comment on lines +96 to 102
pub const StatusCache = extern struct {
/// Placeholder — the deserialized contents are currently discarded. Kept as an
/// extern struct so it can live inline in `SnapshotMetadata` and be persisted
/// alongside the `Manifest` in the rooted DB blob.
_reserved: u8 = 0,

pub fn read(fba: *std.heap.FixedBufferAllocator, r: anytype) !StatusCache {

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.

It would be better to make memory_len align(1) instead of tuning StatusCache to how its stored in its parent.

But in this case, it's fine to persist padding bytes. As long as those padding byte sizes dont change.

Comment thread v2/services/replay.zig
Comment on lines +307 to 310
const manifest = &snapshot_metadata.manifest;
const bhq = &manifest.bank_fields.blockhash_queue;
const hashes = bhq.hashes[0..bhq.hashes_count];

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 u32 doesn't matter, but using getHashes makes sense

Comment on lines +388 to +408
// Persist the Manifest + StatusCache + max FBA bytes
// contiguous blob right after the journal block. Padded to block_size
// so account sectors start block-aligned.
{
const metadata = snapshot_metadata.getSerializable();
const padded = std.mem.alignForward(u64, metadata.len, block_size);
if (padded > std.math.maxInt(u32)) return error.ManifestTooLarge;
self.journal.metadata_bytes = @intCast(padded);

// write unpadded
var r = std.Io.Reader.fixed(metadata);
try self.queueWrite(.from(logger), metadata.len, &r);

// write padding if any
const pad_len = padded - metadata.len;
if (pad_len > 0) try self.queueWrite(.from(logger), pad_len, struct {
pub fn readSliceAll(_: @This(), b: []u8) !void {
@memset(b, 0);
}
}{});
}

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.

Intentional to (naively) rewrite it on root without shifting over sectors on size change

Comment on lines +333 to +337
var epoch_schedule: EpochSchedule = undefined;
try r.readSliceAll(std.mem.asBytes(&epoch_schedule));

var inflation: Inflation = undefined;
try r.readSliceAll(std.mem.asBytes(&inflation));
Comment on lines 672 to 686
// versioned_epoch_stakes: NullOnEof(Vec({ epoch: u64, value: union(enum(u32)) { current: ... } }))
var versioned_epoch_stakes: RelativeSlice(VersionedEpochStakes) = .{};
{
const outer_len = readInt(u64, r) catch |err| switch (err) {
const len = readInt(u64, r) catch |err| switch (err) {
error.EndOfStream => 0,
else => |e| return e,
};
for (0..outer_len) |_| {
try r.discardAll(
8 + // epoch: u64
4, // union tag: u32 (enum(u32), always 'current')
);

// current.epoch_stakes: Stakes(StakeDelegationWithStake)
// vote_accounts: VoteAccounts
try discardVoteAccounts(r);

// stake_delegations: HashMap(Pubkey, { delegation: Delegation, credits_observed: u64 })
const stake_del_len = try readInt(u64, r);
try r.discardAll(stake_del_len * (32 + // key: Pubkey
32 + // delegation.voter_pubkey: Pubkey
8 + // delegation.stake: u64
8 + // delegation.activation_epoch: Epoch
8 + // delegation.deactivation_epoch: Epoch
8 + // delegation.warmup_cooldown_rate: f64
8 // credits_observed: u64
));

try r.discardAll(
8 + // stakes.unused: u64
8, // stakes.epoch: Epoch
);

// stake_history: Vec({ epoch: Epoch, effective: u64, activating: u64, deactivating: u64 })
const sh_len = try readInt(u64, r);
try r.discardAll(sh_len * (8 + 8 + 8 + 8));

// current.total_stake: u64
try r.discardAll(8);

// current.node_id_to_vote_accounts: HashMap(Pubkey, { vote_accounts: Vec(Pubkey), total_stake: u64 })
const nv_len = try readInt(u64, r);
for (0..nv_len) |_| {
// key: Pubkey
try r.discardAll(32);
// value.vote_accounts: Vec(Pubkey)
const va_len = try readInt(u64, r);
try r.discardAll(
va_len * 32 + // vote_accounts: []Pubkey
8, // total_stake: u64
);
}

// current.epoch_authorized_voters: HashMap(Pubkey, Pubkey)
const eav_len = try readInt(u64, r);
try r.discardAll(eav_len * (32 + // key: Pubkey
32 // value: Pubkey
));
const slice = try fba.allocator().alloc(VersionedEpochStakes, len);
for (slice) |*versioned_epoch_stake| {
versioned_epoch_stake.* = try .read(fba, r);
}

versioned_epoch_stakes = .fromSlice(fba.buffer.ptr, slice);
}
/// use that. For reference:
/// - `std.math.pow`: pow(0.85, 4.019250798563942) -> 7.805634650110366e-2
/// - glibc/musl: pow(0.85, 4.019250798563942) -> 7.805634650110367e-2
extern fn pow(f64, f64) f64;

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.

If it fails to link, we'll get a compile error so that is fine. It currently works since we have dependencies like zstd that link to libc anyways.

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

here are some some initial thoughts. still working my way through snapshot.zig

some of the copilot comments seem valid

@@ -1,3 +1,5 @@
// TODO: move this into core

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.

what's core?

also it would be nice if any TODOs we merge to main to have a github issue number as in TODO(1234):

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.

shared/core folder root. v2/lib/solana still has its own duplicates of types from there

Comment thread v2/lib/snapshot.zig
Comment on lines +88 to +138
pub const SnapshotMetadata = extern struct {
slot: std.atomic.Value(u64),

manifest: lib.solana.snapshot.Manifest,
status_cache: lib.solana.snapshot.StatusCache,

memory_len: usize,
memory: [0]u8 align(16), // VLA for [0..memory_len]

// 0 may be a valid slot, so use something that will never be reached.
const invalid_slot = std.math.maxInt(Slot);

pub fn init(self: *SnapshotMetadata, memory_len: usize) void {
self.slot = .init(invalid_slot);
self.memory_len = memory_len;
}

/// Gets a slice of the serializable memory of the SnapshotMetadata
pub fn getSerializable(self: *SnapshotMetadata) []u8 {
// skip the slot & return all bytes from manifest onwards
comptime std.debug.assert(@offsetOf(SnapshotMetadata, "slot") == 0);
comptime std.debug.assert(@offsetOf(SnapshotMetadata, "manifest") == 8);
const header_size = @sizeOf(SnapshotMetadata) - 8;
return @as([*]u8, @ptrCast(&self.manifest))[0 .. header_size + self.memory_len];
}

/// Returns the base pointer used to resolve `RelativeSlice`/`RelativeOffset`
/// values inside `manifest` / `status_cache`.
pub fn getMemory(self: *SnapshotMetadata) []u8 {
return self.memory[0..].ptr[0..self.memory_len];
}

/// Unblocks all getSlotBlocking() callers with the given slot value.
/// Can be called only once.
/// Should also only call after all other SnapshotMetadata fields are populated.
pub fn populateSlot(self: *SnapshotMetadata, slot: Slot) void {
std.debug.assert(slot != invalid_slot);
std.debug.assert(self.slot.swap(slot, .release) == invalid_slot);
}

pub fn getSlotBlocking(self: *SnapshotMetadata, runner: lib.runner.Connection) !Slot {
while (true) {
const slot = self.slot.load(.acquire);
if (slot != invalid_slot) {
try runner.activity.signalActive();
return slot;
}
try runner.activity.signalIdleSpinning();
}
}
};

@dnut dnut Jul 29, 2026

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.

Moving away from ring buffers has made this struct easier to use, but it's still a bit esoteric. You have to know that you're supposed to read getSlotBlocking before reading other fields. It's an unusual design that's not clear from looking at the code and it requires reading the type's documentation to understand how to use it correctly. Personally I prefer when the code is more self explanatory. What do you think about something like this:

pub const SnapshotMetadata = extern struct {
    slot: u64,

    manifest: lib.solana.snapshot.Manifest,
    status_cache: lib.solana.snapshot.StatusCache,

    memory_len: usize,
    memory: [0]u8 align(16), // VLA for [0..memory_len]

    pub fn init(self: *SnapshotMetadata, memory_len: usize) void {
        self.memory_len = memory_len;
    }

    /// Gets a slice of the serializable memory of the SnapshotMetadata
    pub fn getSerializable(self: *SnapshotMetadata) []u8 {
        // skip the slot & return all bytes from manifest onwards
        comptime std.debug.assert(@offsetOf(SnapshotMetadata, "slot") == 0);
        comptime std.debug.assert(@offsetOf(SnapshotMetadata, "manifest") == 8);
        const header_size = @sizeOf(SnapshotMetadata) - 8;
        return @as([*]u8, @ptrCast(&self.manifest))[0 .. header_size + self.memory_len];
    }

    /// Returns the base pointer used to resolve `RelativeSlice`/`RelativeOffset`
    /// values inside `manifest` / `status_cache`.
    pub fn getMemory(self: *SnapshotMetadata) []u8 {
        return self.memory[0..].ptr[0..self.memory_len];
    }
};

/// Stored in the region
pub const SharedSnapshotMetadata = extern struct {
    is_ready: std.atomic.Value(bool),
    /// not safe to read until is_ready is true
    private: SnapshotMetadata,

    /// Unblocks all getBlocking() callers.
    /// Can be called only once.
    /// Should also only call after all other SnapshotMetadata fields are populated.
    pub fn finalize(self: *SharedSnapshotMetadata) void {
        std.debug.assert(self.is_ready.swap(true, .release) == false);
    }

    pub fn getBlocking(
        self: *SharedSnapshotMetadata,
        runner: lib.runner.Connection,
    ) !*SnapshotMetadata {
        while (true) {
            if (self.is_ready.load(.acquire)) {
                try runner.activity.signalActive();
                return &self.private;
            }
            try runner.activity.signalIdleSpinning();
        }
    }
};

Comment on lines +63 to +64
pub fn isNull(self: Self) bool {
return self.offset == 0;

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 does every type need to be nullable? I'd rather express this in the concrete type by using an optional type as T if necessary.

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.

RelativeOffset is stored in Manifest/StatusCache and must be extern/region compatible. Zig's optionals (like slices) dont have a stable ABI

Comment on lines +192 to +193
vote_accounts: RelativeSlice(Pubkey), // acc.data contains stake for the voter
stake_accounts: RelativeSlice(Pubkey), // acc.data contains voter pubkey + Delegation

@dnut dnut Jul 30, 2026

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.

Your intent is for replay's bootstrap process to fetch all these accounts from accountsdb before starting replay? Avoiding that would require sending 8 extra bytes per vote account, and 64 extra bytes per stake account. Is that a problem?

StakeDelegation {
    voter_pubkey:       Pubkey,   // 32   which vote account this stake delegates to
    stake:              u64,      //  8   delegated lamports
    activation_epoch:   Epoch,    //  8   warmup/cooldown math needs both epochs
    deactivation_epoch: Epoch,    //  8
    credits_observed:   u64,      //  8   reward = (voter_credits_now − credits_observed) * pts
}

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.

Yes. It'll have to fetch them from AccountsDB to verify that the data in the snapshot manifest matches the accounts on disk: https://github.com/anza-xyz/agave/blob/master/runtime/src/stakes.rs#L344

This struct just then contains what to fetch, where we'll use the account data directly instead of verifying fields that the snapshot manifest presented. Should we still verify those fields?

Comment on lines +514 to +515
node_to_vote_accounts: RelativeSlice(NodeToVoterEntry),
epoch_authorized_voters: RelativeSlice(AuthToVoterEntry),

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.

Do we actually need these two fields for anything?

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🏗 In progress

Development

Successfully merging this pull request may close these issues.

5 participants