Skip to content

qkc/cluster: add pyquarkchain-compatible cluster protocol wire library#19

Draft
iteyelmp wants to merge 12 commits into
goshard/basefrom
cluster
Draft

qkc/cluster: add pyquarkchain-compatible cluster protocol wire library#19
iteyelmp wants to merge 12 commits into
goshard/basefrom
cluster

Conversation

@iteyelmp

@iteyelmp iteyelmp commented Jun 22, 2026

Copy link
Copy Markdown

This PR implements the cluster protocol wire library for the Go slave, providing byte-compatible communication with the Python master and other slaves. It addresses issue #5.

What's included

Protocol layer (cluster package)

Frame codec (frame.go)

  • Binary-compatible frame encoding/decoding matching pyquarkchain's protocol.py wire format: [4B len] [12B metadata] [1B opcode] [8B rpc_id] [payload]
  • Read/write with single-allocation buffer, no extra copying

Master connection (master_conn.go)

  • Single TCP connection to Python master, multiplexed by cluster_peer_id
  • RPC request/response matching via pending map + time-based rpc_id
  • Inbound frame dispatching through an OnFrame callback chain

Dispatcher (master_dispatcher.go)

  • Routes every inbound frame by cluster_peer_id:
    • == 0 → MasterConn.Handle() (cluster RPC, master commands)
    • != 0 → PeerConn.Dispatch() (virtual P2P, external peer traffic)

Peer virtual connection (peer_conn.go)

  • Simulates a direct P2P connection over the same physical TCP to master
  • When an external peer connects to the master, master sends CREATE_CLUSTER_PEER_CONNECTION_REQUEST; slave creates one PeerConn per owned branch
  • Frames from the peer arrive on the master TCP with cluster_peer_id != 0, get routed by Dispatcher to the matching PeerConn
  • PeerConn can also send frames back to the peer via masterConn.WriteFrame (responses, queries, etc.)
  • Created/removed dynamically; one per branch per cluster peer

Xshard direct TCP (xshard_conn.go, xshard_pool.go)

  • Separate physical TCP connection for Slave↔Slave cross-shard traffic (does NOT go through master)
  • Connection pool indexed by FullShardID for routing
  • PING handshake: every accepted inbound xshard connection waits for the first PING before being indexed (matches Python's wait_until_ping_received); remote id and full_shard_id_list are recorded on the conn by recordPing

SlaveRPC (slave_rpc.go)

  • Single public entry point: NewSlaveRPC(cfg)RegisterHandlers()
    Serve()
  • Internal *Slave is completely hidden from callers
  • RegisterHandlers() registers all INBOUND handlers:
    • Group 1 (Master → Slave): 30 ClusterOp REQUEST handlers
    • Group 2 (Slave ↔ Slave): 3 Xshard handlers (applied to every XshardConn, inbound and outbound)
    • Group 3 (Peer → Slave): 6 CommandOp handlers (applied to every PeerConn, shared across all peer connections)
  • Outbound communication uses separate typed methods, not registered handlers

Message structs (messages.go)

  • Wire-compatible structs with qkc/serialize tags for every cluster RPC
  • Operational types defined now; types needing MinorBlockHeader, TypedTransaction, etc. are marked as TODO

Protocol constants (protocol.go)

  • All 68 ClusterOp codes (0x81–0xC4) and 21 CommandOp codes (0x00–0x14)
  • Request/Response pairing convention documented
  • Ownership annotations: which opcodes are Master-only vs forwarded to Slave

Runtime flow

The slave goes through a fixed connection lifecycle that mirrors the Python implementation. Understanding this order is critical for wire compatibility debugging.

Step 1: Bootstrap — Master connects first

SlaveRPC.Serve() opens the listen socket and calls acceptLoop. The very first TCP connection accepted is treated as the master connection (matches Python Slave.handle_new_connection which sets self.master_conn on the first accept). All subsequent connections are treated as inbound Slave↔Slave xshard connections.

The master then sends PING with metadata = (Branch=0, ClusterPeerID=0) and PingRequest{ID, FullShardIDList}. The slave responds with PongResponse{ID, FullShardIDList}. This is the only Mode 1 frame the
master initiates; afterwards the slave is considered "online".

Step 2: Master triggers inter-slave mesh — CONNECT_TO_SLAVES

The master sends CONNECT_TO_SLAVES_REQUEST with a SlaveInfoList of peers (id, host, port, full_shard_id_list). For each entry the slave:

  1. Skips self (by id) and already-connected slaves (deduplication via connectedSlaveIDs set, matches Python slave_ids)
  2. Dials the remote slave's TCP port directly (NewXshardConn)
  3. Applies the SLAVE_OP_RPC_MAP handlers to the new conn (PING, ADD_XSHARD_TX_LIST, BATCH_ADD_XSHARD_TX_LIST)
  4. Starts the read loop, then sends PING carrying the local id and shard list and waits for PONG
  5. Verifies the remote PongResponse.ID and FullShardIDList match what the master advertised — mismatch closes the conn with an error
  6. Indexes the conn by every remote fullShardID in xshardPool (key: FullShardID{ChainID, ShardID} — high 16 bits / low 16 bits)
  7. Records the remote id in connectedSlaveIDs so the reciprocal inbound connection is not re-dialled

Step 3: Inbound xshard handshake — acceptLoop else branch

When another slave dials in (the reciprocal of Step 2), acceptLoopwraps the raw conn in an XshardConn, applies the same SLAVE_OP_RPC_MAPhandlers, tracks it in xshardPool.TrackInbound, then spawns a goroutine that blocks on WaitUntilPingReceived before indexing:

  • If the peer never sends PING (timeout / disconnect), the conn is closed and never enters the routing map
  • On first PING, recordPing stores remoteID and remoteFullShardIDList on the conn and closes the pingReceived signal channel
  • The goroutine then indexes the conn into xshardPool by every peer shard, and adds the remote id to connectedSlaveIDs

This two-sided handshake guarantees that xshardPool is only populated with verified peers, matching Python's SlaveConnectionManager flow.

Step 4: External peer attaches — CREATE_CLUSTER_PEER_CONNECTION

When an external P2P peer connects to the master, the master broadcasts CREATE_CLUSTER_PEER_CONNECTION_REQUEST to every slave. Wire compatibility note: the master sends this with metadata = ClusterMetadata(ROOT_BRANCH=0, cluster_peer_id=0); the real cluster_peer_id lives in the payload (CreateClusterPeerConnectionRequest.cluster_peer_id). The slave must deserialize the payload to read it — reading from frame.Meta.ClusterPeerID would always yield 0 and break all PeerConn routing. The slave then:

  1. Records the cluster_peer_id in clusterPeerIDs set
  2. For every owned branch, creates a PeerConn backed by the same masterConn, applies the peer-shard CommandOp handlers, and registers it in Dispatcher.peerConns[cluster_peer_id][branch]
  3. Subsequent peer frames arrive on the master TCP with metadata = (branch, cluster_peer_id), and Dispatcher.Dispatch routes them to the matching PeerConn
  4. DESTROY_CLUSTER_PEER_CONNECTION_COMMAND (also carrying cluster_peer_id in the payload) tears it all down

Step 5: Ongoing traffic

After handshake the slave handles three traffic classes concurrently:

  • Mode 1 (Master ↔ Slave): cluster RPC on the master TCP, cluster_peer_id == 0MasterConn.Handle
  • Mode 2 (Slave ↔ Slave): xshard RPC on dedicated TCPs, routed via xshardPool by FullShardID
  • Mode 3 (Peer → Master → Slave): virtual P2P on the master TCP, cluster_peer_id != 0Dispatcher.DispatchPeerConn.HandleFrame

Design decisions

  • Two-layer architecture: Slave (raw frames, opcode bytes) is internal; SlaveRPC (typed methods, serialization) is the public API
  • Response opcodes auto-generated: MasterConn.Handle() produces RESPONSE = REQUEST + 1 — only REQUEST opcodes need handler registration
  • Stubs for unimplemented handlers: 35/39 opcodes return ErrNotImplemented with debug logging; shim layer calls RegisterPeerHandler() to replace stubs with real implementations later
  • Single source of truth for xshard handlers: Slave.xshardHandlers is applied to both outbound (in ConnectToSlaves) and inbound (in acceptLoop else branch) XshardConns, matching Python's SLAVE_OP_RPC_MAP usage

Testing

35 tests across the three communication modes:

  • Frame codec (9): round-trip, wire format byte-level validation, edge cases (truncated, empty, large payload, zero rpc_id)
  • MasterConn (4): RPC request/response, handler registration, fire-and-forget
  • Dispatcher (1): routing end-to-end
  • Slave lifecycle (9): full lifecycle, PING/PONG, create/destroy peer connections, idempotent close, xshard server, xshard pool
  • SlaveRPC integration (8): all three modes, including a 4-step full integration test (PING → CONNECT_TO_SLAVES → PEER_ROUTING → SEND)
  • Real end-to-end (2):
    • TestE2E_TwoSlavesHandshake: two real Go Slaves talking to each other over TCP, verifying bidirectional PING handshake, bidirectional recordPing, bidirectional xshardPool indexing, and a real ADD_XSHARD_TX_LIST delivery from A to B
    • TestE2E_PythonMasterPeerRouting: simulates Python master's exact broadcast_rpc wire behavior over real TCP — verifies cluster_peer_id is read from the payload on CREATE_CLUSTER_PEER_CONNECTION, PeerConn creation, Dispatcher routing to PeerConn, and PeerConn removal on DESTROY
  • Serialization round-trips (3): PingRequest, PongResponse, SlaveInfo

@qzhodl

qzhodl commented Jul 1, 2026

Copy link
Copy Markdown

How did you test it?

@iteyelmp iteyelmp marked this pull request as draft July 1, 2026 10:46
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.

2 participants