proto-socket/go/inbound_gateway.go
toki 2ec43f2d00 feat: inbound queue ordering - Go gateway implementation
- Add inbound_gateway.go and inbound_gateway_test.go
- Update communicator.go with nonce handling improvements
- Update tcp_client.go and ws_client.go
- Update communicator_test.go and ws_test.go
- Move old Go gateway docs to archive
2026-06-02 11:43:10 +09:00

228 lines
6.9 KiB
Go

package proto_socket
import (
"runtime"
"sync"
"google.golang.org/protobuf/proto"
"git.toki-labs.com/toki/proto-socket/go/packets"
)
// InboundFrame is a raw inbound frame queued for gateway preprocessing.
//
// Seq is an internal monotonically increasing sequence assigned by the receive
// side before submitting to the gateway. It is used only to restore input order
// after parallel decoding and never touches the wire format or any public
// protocol field.
type InboundFrame struct {
Seq int64
Bytes []byte
}
// DecodedFrame is the result of decoding a PacketBase envelope inside a gateway.
//
// It carries the same envelope fields that Communicator.OnReceivedData expects,
// plus the originating Seq so results can be reordered before dispatch.
type DecodedFrame struct {
Seq int64
TypeName string
Data []byte
IncomingNonce int32
ResponseNonce int32
}
// InboundGateway performs only pure raw-bytes work (PacketBase envelope decode /
// lightweight preprocessing) off the receive coordinator. It must NOT own
// stateful dispatch, the pending-request map, listeners, or the outbound write
// queue — those stay on the receive coordinator. Decoded results are delivered
// to a sink in input Seq order so the coordinator can dispatch deterministically.
type InboundGateway interface {
// Submit hands a raw frame to the gateway. Fire-and-forget; the decoded
// result is later delivered to the sink in Seq order.
Submit(frame InboundFrame)
// Close releases gateway resources and stops delivering results.
Close()
}
// decodeFunc decodes a raw frame into a PacketBase envelope. Pure: no shared
// state, safe to run concurrently across workers.
type decodeFunc func([]byte) (*packets.PacketBase, error)
func decodePacketBase(raw []byte) (*packets.PacketBase, error) {
base := &packets.PacketBase{}
if err := proto.Unmarshal(raw, base); err != nil {
return nil, err
}
return base, nil
}
// workerResult is a per-frame decode outcome carried through the reorder buffer.
// err frames still advance the sequence so a single bad frame cannot stall the
// reorder window.
type workerResult struct {
seq int64
frame DecodedFrame
err error
}
// frameReorderBuffer restores input order from results that may arrive out of
// order. Sequences are expected to be contiguous starting at 1. A result is
// buffered until every earlier sequence has been released, then it and any
// now-contiguous successors are returned in order. It is not safe for concurrent
// use; the WorkerGateway confines it to the single collector goroutine.
type frameReorderBuffer struct {
nextSeq int64
pending map[int64]workerResult
}
func newFrameReorderBuffer() *frameReorderBuffer {
return &frameReorderBuffer{nextSeq: 1, pending: make(map[int64]workerResult)}
}
// release buffers res and returns the results now releasable in seq order.
func (b *frameReorderBuffer) release(res workerResult) []workerResult {
if res.seq < b.nextSeq {
// Already released (or duplicate); ignore to avoid emitting twice.
return nil
}
b.pending[res.seq] = res
var ready []workerResult
for {
r, ok := b.pending[b.nextSeq]
if !ok {
break
}
ready = append(ready, r)
delete(b.pending, b.nextSeq)
b.nextSeq++
}
return ready
}
// WorkerGateway is a goroutine worker-pool gateway. A bounded jobs channel feeds
// N workers that decode PacketBase envelopes in parallel; a single collector
// goroutine reorders the results by Seq and delivers them to the sink in input
// order. Because the collector is the only goroutine that calls the sink, sink
// invocations are serialized and ordered, keeping stateful dispatch on the
// receive coordinator.
type WorkerGateway struct {
jobs chan InboundFrame
results chan workerResult
done chan struct{}
collectorDone chan struct{}
wg sync.WaitGroup
closeOnce sync.Once
parse decodeFunc
sink func(DecodedFrame)
onError func(error)
}
// NewWorkerGateway creates a worker-pool gateway that decodes PacketBase frames
// and delivers them to sink in Seq order. A frame that fails to decode is not
// sent to sink; instead onError is invoked with the decode error in Seq order so
// the transport can apply its existing parse-error disconnect semantics. The
// gateway itself never tears down the transport — onError keeps that decision on
// the caller. onError may be nil to ignore decode errors.
//
// workers <= 0 defaults to NumCPU and queueSize <= 0 defaults to 64. The bounded
// jobs channel provides backpressure: Submit blocks once it is full until a
// worker frees a slot.
func NewWorkerGateway(workers, queueSize int, sink func(DecodedFrame), onError func(error)) *WorkerGateway {
return newWorkerGateway(workers, queueSize, decodePacketBase, sink, onError)
}
func newWorkerGateway(workers, queueSize int, parse decodeFunc, sink func(DecodedFrame), onError func(error)) *WorkerGateway {
if workers <= 0 {
workers = runtime.NumCPU()
}
if queueSize <= 0 {
queueSize = 64
}
g := &WorkerGateway{
jobs: make(chan InboundFrame, queueSize),
results: make(chan workerResult, queueSize),
done: make(chan struct{}),
collectorDone: make(chan struct{}),
parse: parse,
sink: sink,
onError: onError,
}
for i := 0; i < workers; i++ {
g.wg.Add(1)
go g.worker()
}
go g.collector()
return g
}
func (g *WorkerGateway) worker() {
defer g.wg.Done()
for {
select {
case frame := <-g.jobs:
res := workerResult{seq: frame.Seq}
base, err := g.parse(frame.Bytes)
if err != nil {
res.err = err
} else {
res.frame = DecodedFrame{
Seq: frame.Seq,
TypeName: base.GetTypeName(),
Data: base.GetData(),
IncomingNonce: base.GetNonce(),
ResponseNonce: base.GetResponseNonce(),
}
}
select {
case g.results <- res:
case <-g.done:
return
}
case <-g.done:
return
}
}
}
func (g *WorkerGateway) collector() {
defer close(g.collectorDone)
reorder := newFrameReorderBuffer()
for {
select {
case res := <-g.results:
for _, ready := range reorder.release(res) {
if ready.err != nil {
// Report the decode error in Seq order and keep going so a
// single bad frame does not stall later frames. The caller's
// onError decides whether to tear down the transport.
if g.onError != nil {
g.onError(ready.err)
}
continue
}
g.sink(ready.frame)
}
case <-g.done:
return
}
}
}
// Submit hands a raw frame to the worker pool. It blocks while the bounded jobs
// channel is full (backpressure) and returns immediately once the gateway is
// closed.
func (g *WorkerGateway) Submit(frame InboundFrame) {
select {
case g.jobs <- frame:
case <-g.done:
}
}
// Close signals all workers and the collector to stop. It is idempotent and
// does not block on in-flight sink calls.
func (g *WorkerGateway) Close() {
g.closeOnce.Do(func() {
close(g.done)
})
}