- 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
228 lines
6.9 KiB
Go
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)
|
|
})
|
|
}
|