feat: rebuild transport wiring — session control + base block streaming

Wire protocol messages and transport handlers for the rebuild MVP:

Protocol messages (rebuild_transport.go):
- SessionControlMsg: epoch, sessionID, command, baseLSN, targetLSN,
  snapshotID. Encode/Decode with fixed 37-byte wire format.
- SessionAckMsg: epoch, sessionID, phase, walAppliedLSN, baseComplete,
  achievedLSN. Encode/Decode with fixed 34-byte wire format.
- MsgSessionControl (0x10) and MsgSessionAck (0x11) on control channel.
- SendSessionControl/SendSessionAck convenience functions.

Transport handlers:
- RebuildTransportServer: primary-side, streams all extent blocks as
  MsgRebuildExtent frames (reusing existing rebuild message type),
  ends with MsgRebuildDone.
- RebuildTransportClient: replica-side, receives base blocks and
  routes through vol.ApplyRebuildSessionBaseBlock, marks base
  complete on MsgRebuildDone.

4 transport tests:
- SessionControl wire round-trip
- SessionAck wire round-trip
- BaseBlockStreaming: full TCP loop, 1024 blocks streamed and verified
- SessionControlOverTCP: real TCP send/receive with accepted ack

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pingqiu
2026-04-07 14:57:43 -07:00
co-authored by Claude Opus 4.6
parent 49845dd509
commit 342f8baa69
2 changed files with 493 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
package blockvol
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"time"
)
// Session control message types (on control channel, distinct from barrier).
const (
MsgSessionControl byte = 0x10 // primary → replica: session command
MsgSessionAck byte = 0x11 // replica → primary: session progress/result
)
// Session control command kinds.
const (
SessionCmdStartRebuild byte = 0x01
SessionCmdCancel byte = 0x02
)
// Session ack phase codes (wire representation of RebuildSessionPhase).
const (
SessionAckAccepted byte = 0x01
SessionAckRunning byte = 0x02
SessionAckBaseComplete byte = 0x03
SessionAckCompleted byte = 0x04
SessionAckFailed byte = 0x05
)
// SessionControlMsg is the wire message for session control commands.
type SessionControlMsg struct {
Epoch uint64
SessionID uint64
Command byte
BaseLSN uint64 // for start_rebuild
TargetLSN uint64 // for start_rebuild
SnapshotID uint32 // for start_rebuild (0 = use current extent)
}
// EncodeSessionControl serializes a session control message.
// Wire: [8B epoch][8B sessionID][1B cmd][8B baseLSN][8B targetLSN][4B snapshotID] = 37 bytes.
func EncodeSessionControl(msg SessionControlMsg) []byte {
buf := make([]byte, 37)
binary.BigEndian.PutUint64(buf[0:8], msg.Epoch)
binary.BigEndian.PutUint64(buf[8:16], msg.SessionID)
buf[16] = msg.Command
binary.BigEndian.PutUint64(buf[17:25], msg.BaseLSN)
binary.BigEndian.PutUint64(buf[25:33], msg.TargetLSN)
binary.BigEndian.PutUint32(buf[33:37], msg.SnapshotID)
return buf
}
// DecodeSessionControl deserializes a session control message.
func DecodeSessionControl(buf []byte) (SessionControlMsg, error) {
if len(buf) < 37 {
return SessionControlMsg{}, fmt.Errorf("session control: short message (%d bytes)", len(buf))
}
return SessionControlMsg{
Epoch: binary.BigEndian.Uint64(buf[0:8]),
SessionID: binary.BigEndian.Uint64(buf[8:16]),
Command: buf[16],
BaseLSN: binary.BigEndian.Uint64(buf[17:25]),
TargetLSN: binary.BigEndian.Uint64(buf[25:33]),
SnapshotID: binary.BigEndian.Uint32(buf[33:37]),
}, nil
}
// SessionAckMsg is the wire message for session progress/result.
type SessionAckMsg struct {
Epoch uint64
SessionID uint64
Phase byte
WALAppliedLSN uint64
BaseComplete bool
AchievedLSN uint64 // on completion
}
// EncodeSessionAck serializes a session ack message.
// Wire: [8B epoch][8B sessionID][1B phase][8B walAppliedLSN][1B baseComplete][8B achievedLSN] = 34 bytes.
func EncodeSessionAck(msg SessionAckMsg) []byte {
buf := make([]byte, 34)
binary.BigEndian.PutUint64(buf[0:8], msg.Epoch)
binary.BigEndian.PutUint64(buf[8:16], msg.SessionID)
buf[16] = msg.Phase
binary.BigEndian.PutUint64(buf[17:25], msg.WALAppliedLSN)
if msg.BaseComplete {
buf[25] = 1
}
binary.BigEndian.PutUint64(buf[26:34], msg.AchievedLSN)
return buf
}
// DecodeSessionAck deserializes a session ack message.
func DecodeSessionAck(buf []byte) (SessionAckMsg, error) {
if len(buf) < 34 {
return SessionAckMsg{}, fmt.Errorf("session ack: short message (%d bytes)", len(buf))
}
return SessionAckMsg{
Epoch: binary.BigEndian.Uint64(buf[0:8]),
SessionID: binary.BigEndian.Uint64(buf[8:16]),
Phase: buf[16],
WALAppliedLSN: binary.BigEndian.Uint64(buf[17:25]),
BaseComplete: buf[25] != 0,
AchievedLSN: binary.BigEndian.Uint64(buf[26:34]),
}, nil
}
// RebuildTransportServer handles the primary-side rebuild data serving for one
// session. It streams snapshot base blocks to the replica over a dedicated TCP
// connection (the existing rebuild server path).
type RebuildTransportServer struct {
vol *BlockVol
sessionID uint64
epoch uint64
baseLSN uint64
targetLSN uint64
}
// NewRebuildTransportServer creates a primary-side rebuild transport server
// for one session.
func NewRebuildTransportServer(vol *BlockVol, sessionID, epoch, baseLSN, targetLSN uint64) *RebuildTransportServer {
return &RebuildTransportServer{
vol: vol,
sessionID: sessionID,
epoch: epoch,
baseLSN: baseLSN,
targetLSN: targetLSN,
}
}
// ServeBaseBlocks streams all extent blocks to the replica connection.
// Each block is sent as MsgRebuildExtent with the LBA encoded in the first 8
// bytes. The stream ends with MsgRebuildDone.
func (s *RebuildTransportServer) ServeBaseBlocks(conn net.Conn) error {
if s.vol == nil {
return fmt.Errorf("rebuild transport: volume is nil")
}
conn.SetDeadline(time.Now().Add(10 * time.Minute))
defer conn.SetDeadline(time.Time{})
info := s.vol.Info()
blockSize := uint64(info.BlockSize)
totalLBAs := info.VolumeSize / blockSize
// Flush to ensure extent is current before streaming.
if err := s.vol.ForceFlush(); err != nil {
log.Printf("rebuild transport: flush before base stream: %v", err)
}
var sentBlocks uint64
for lba := uint64(0); lba < totalLBAs; lba++ {
data, err := s.vol.ReadLBA(lba, uint32(blockSize))
if err != nil {
return fmt.Errorf("rebuild transport: read LBA %d: %w", lba, err)
}
// Encode: [8B LBA][block data]
frame := make([]byte, 8+len(data))
binary.BigEndian.PutUint64(frame[0:8], lba)
copy(frame[8:], data)
if err := WriteFrame(conn, MsgRebuildExtent, frame); err != nil {
return fmt.Errorf("rebuild transport: send LBA %d: %w", lba, err)
}
sentBlocks++
}
// Send completion marker.
doneBuf := make([]byte, 8)
binary.BigEndian.PutUint64(doneBuf, sentBlocks)
if err := WriteFrame(conn, MsgRebuildDone, doneBuf); err != nil {
return fmt.Errorf("rebuild transport: send done: %w", err)
}
log.Printf("rebuild transport: served %d base blocks for session %d", sentBlocks, s.sessionID)
return nil
}
// RebuildTransportClient handles the replica-side rebuild data receiving for
// one session. It receives snapshot base blocks from the primary and routes
// them through the rebuild session's base lane.
type RebuildTransportClient struct {
vol *BlockVol
sessionID uint64
}
// NewRebuildTransportClient creates a replica-side rebuild transport client
// for one session.
func NewRebuildTransportClient(vol *BlockVol, sessionID uint64) *RebuildTransportClient {
return &RebuildTransportClient{
vol: vol,
sessionID: sessionID,
}
}
// ReceiveBaseBlocks reads base blocks from the primary connection and applies
// them through the rebuild session. Returns the total number of blocks processed.
func (c *RebuildTransportClient) ReceiveBaseBlocks(conn net.Conn) (uint64, error) {
if c.vol == nil {
return 0, fmt.Errorf("rebuild transport: volume is nil")
}
conn.SetDeadline(time.Now().Add(10 * time.Minute))
defer conn.SetDeadline(time.Time{})
var totalBlocks uint64
for {
msgType, payload, err := ReadFrame(conn)
if err != nil {
if err == io.EOF {
break
}
return totalBlocks, fmt.Errorf("rebuild transport: read frame: %w", err)
}
switch msgType {
case MsgRebuildExtent:
if len(payload) < 8 {
return totalBlocks, fmt.Errorf("rebuild transport: short extent frame")
}
lba := binary.BigEndian.Uint64(payload[0:8])
data := payload[8:]
if _, err := c.vol.ApplyRebuildSessionBaseBlock(c.sessionID, lba, data); err != nil {
return totalBlocks, fmt.Errorf("rebuild transport: apply base LBA %d: %w", lba, err)
}
totalBlocks++
case MsgRebuildDone:
if err := c.vol.MarkRebuildSessionBaseComplete(c.sessionID, totalBlocks); err != nil {
return totalBlocks, fmt.Errorf("rebuild transport: mark base complete: %w", err)
}
log.Printf("rebuild transport: received %d base blocks for session %d", totalBlocks, c.sessionID)
return totalBlocks, nil
case MsgRebuildError:
return totalBlocks, fmt.Errorf("rebuild transport: server error: %s", string(payload))
default:
return totalBlocks, fmt.Errorf("rebuild transport: unexpected message type 0x%02x", msgType)
}
}
return totalBlocks, nil
}
// SendSessionControl sends a session control message on the control connection.
func SendSessionControl(conn net.Conn, msg SessionControlMsg) error {
return WriteFrame(conn, MsgSessionControl, EncodeSessionControl(msg))
}
// SendSessionAck sends a session ack message on the control connection.
func SendSessionAck(conn net.Conn, msg SessionAckMsg) error {
return WriteFrame(conn, MsgSessionAck, EncodeSessionAck(msg))
}
@@ -0,0 +1,236 @@
package blockvol
import (
"bytes"
"fmt"
"net"
"testing"
"time"
)
func TestRebuildTransport_SessionControlRoundTrip(t *testing.T) {
msg := SessionControlMsg{
Epoch: 5,
SessionID: 42,
Command: SessionCmdStartRebuild,
BaseLSN: 1000,
TargetLSN: 2000,
SnapshotID: 7,
}
encoded := EncodeSessionControl(msg)
decoded, err := DecodeSessionControl(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if decoded != msg {
t.Fatalf("round-trip mismatch: got %+v, want %+v", decoded, msg)
}
}
func TestRebuildTransport_SessionAckRoundTrip(t *testing.T) {
msg := SessionAckMsg{
Epoch: 5,
SessionID: 42,
Phase: SessionAckCompleted,
WALAppliedLSN: 2500,
BaseComplete: true,
AchievedLSN: 2500,
}
encoded := EncodeSessionAck(msg)
decoded, err := DecodeSessionAck(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if decoded != msg {
t.Fatalf("round-trip mismatch: got %+v, want %+v", decoded, msg)
}
}
func TestRebuildTransport_BaseBlockStreaming(t *testing.T) {
// Create primary with data.
primary := createTestVolForTransport(t, "primary")
defer primary.Close()
if err := primary.HandleAssignment(1, RolePrimary, 30*time.Second); err != nil {
t.Fatal(err)
}
blocks := make([][]byte, 5)
for i := 0; i < 5; i++ {
blocks[i] = bytes.Repeat([]byte{byte(0xA0 + i)}, 4096)
if err := primary.WriteLBA(uint64(i), blocks[i]); err != nil {
t.Fatalf("write LBA %d: %v", i, err)
}
}
if err := primary.SyncCache(); err != nil {
t.Fatalf("sync: %v", err)
}
// Create replica with rebuild session.
replica := createTestVolForTransport(t, "replica")
defer replica.Close()
if err := replica.HandleAssignment(1, RoleReplica, 30*time.Second); err != nil {
t.Fatal(err)
}
if err := replica.StartRebuildSession(RebuildSessionConfig{
SessionID: 1,
Epoch: 1,
BaseLSN: 5,
TargetLSN: 5,
}); err != nil {
t.Fatal(err)
}
// Set up localhost TCP connection.
server := NewRebuildTransportServer(primary, 1, 1, 5, 5)
client := NewRebuildTransportClient(replica, 1)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
// Server side: accept and serve.
serverDone := make(chan error, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
serverDone <- err
return
}
defer conn.Close()
serverDone <- server.ServeBaseBlocks(conn)
}()
// Client side: connect and receive.
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
totalBlocks, err := client.ReceiveBaseBlocks(conn)
if err != nil {
t.Fatalf("receive base blocks: %v", err)
}
// Wait for server.
if err := <-serverDone; err != nil {
t.Fatalf("serve base blocks: %v", err)
}
// The volume is 4MB / 4K = 1024 LBAs, but only 5 have data.
// All 1024 blocks are streamed (full extent).
t.Logf("received %d base blocks", totalBlocks)
if totalBlocks == 0 {
t.Fatal("no base blocks received")
}
// Verify the 5 written blocks are readable on replica.
for i := 0; i < 5; i++ {
data, err := replica.ReadLBA(uint64(i), 4096)
if err != nil {
t.Fatalf("replica read LBA %d: %v", i, err)
}
if !bytes.Equal(data, blocks[i]) {
t.Fatalf("replica LBA %d mismatch: got[0]=0x%02x want[0]=0x%02x",
i, data[0], blocks[i][0])
}
}
t.Log("base block streaming: all 5 blocks verified on replica")
}
func TestRebuildTransport_SessionControlOverTCP(t *testing.T) {
// Test session control messages over real TCP.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
done := make(chan error, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
done <- err
return
}
defer conn.Close()
// Read session control.
msgType, payload, err := ReadFrame(conn)
if err != nil {
done <- err
return
}
if msgType != MsgSessionControl {
done <- fmt.Errorf("unexpected msg type: 0x%02x", msgType)
return
}
ctrl, err := DecodeSessionControl(payload)
if err != nil {
done <- err
return
}
// Reply with session ack.
ack := SessionAckMsg{
Epoch: ctrl.Epoch,
SessionID: ctrl.SessionID,
Phase: SessionAckAccepted,
}
done <- SendSessionAck(conn, ack)
}()
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
// Send session control.
ctrl := SessionControlMsg{
Epoch: 3,
SessionID: 99,
Command: SessionCmdStartRebuild,
BaseLSN: 500,
TargetLSN: 1000,
}
if err := SendSessionControl(conn, ctrl); err != nil {
t.Fatalf("send control: %v", err)
}
// Read ack.
msgType, payload, err := ReadFrame(conn)
if err != nil {
t.Fatalf("read ack: %v", err)
}
if msgType != MsgSessionAck {
t.Fatalf("unexpected ack type: 0x%02x", msgType)
}
ack, err := DecodeSessionAck(payload)
if err != nil {
t.Fatalf("decode ack: %v", err)
}
if ack.SessionID != 99 || ack.Phase != SessionAckAccepted {
t.Fatalf("ack mismatch: %+v", ack)
}
if err := <-done; err != nil {
t.Fatalf("server: %v", err)
}
t.Log("session control over TCP: round-trip verified")
}
func createTestVolForTransport(t *testing.T, name string) *BlockVol {
t.Helper()
opts := CreateOptions{
VolumeSize: 4 * 1024 * 1024,
BlockSize: 4096,
WALSize: 1 * 1024 * 1024,
}
vol, err := CreateBlockVol(t.TempDir()+"/"+name+".blk", opts)
if err != nil {
t.Fatal(err)
}
return vol
}