Files
go/rpc/pipeline.go
zeekay 9f22e43b50 rpc: real Target-based promise pipelining (Session + Pipeliner)
The call envelope has always carried Target @8, but zapgen hardcoded
Target: rpc.NoTarget in every generated method, so Go could DECODE a
pipelined envelope yet never ORIGINATE or RESOLVE one — pipelining was a
no-op. Make the canonical Target model real on both sides.

Client (origination): rpc.Session allocates unique non-zero PromiseIDs and
builds Calls — Origin (Target=NoTarget) and Pipeline (Target=prior.ID).
zapgen now emits a *rpc.Session-backed client: M(...) originates, MOn(on
rpc.Promise) pipelines on a prior answer (payload supplied server-side).
NoTarget is no longer hardcoded — it flows through one shared invoke<M>
path. Both forms return the call's own rpc.Promise so chains compose.

Server (resolution): rpc.Pipeliner wraps a generated Dispatch<Iface> and is
the per-connection promise table: records each OK answer under its
PromiseID, substitutes a resolved Target's Body for the dependent's Payload
before dispatch, QUEUES a dependent whose Target hasn't resolved yet until
it does, and REFUSES (StatusBadRequest, never hangs) a dependent whose
Target answered non-OK or was Finished. Finish(id) bounds the table.

Wire-compatible: Target=NoTarget is the non-pipelined call, so a Pipeliner
and a plain dispatcher are byte-identical on the wire; a non-pipelining
peer (any other runtime) interoperates trivially.

Proven end-to-end in rpc/pipeline_test.go (auth->getResource): single-pass
resolution, server-side queuing when the dependent arrives first, a 3-deep
A->B->C chain, non-OK/Finished targets refused, Finish waking a parked
dependent, and Target riding on the wire byte-for-byte. Full suite green
under -race. Docs: README + LLM.md describe the one canonical model and the
Rust capnp PromisedAnswer boundary honestly.
2026-06-21 01:49:48 -07:00

301 lines
11 KiB
Go

// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package rpc
import (
"fmt"
"sync"
)
// Promise pipelining (the ONE canonical ZAP model).
//
// A call carries a caller-assigned PromiseID (the id its answer resolves
// to). A *dependent* call sets Target = a prior call's PromiseID, meaning
// "before you dispatch me, substitute the resolved Body of the call that
// answered to that PromiseID as my Payload". The result of A is the input
// to B, so B never waits for A's Body to round-trip back to the caller —
// both calls ship back-to-back and the server chains them.
//
// Two pieces implement it, each in one place (orthogonal, no braiding):
//
// - Session (client side) allocates PromiseIDs and stamps Target onto a
// dependent Call, so a caller can reference a prior in-flight answer.
// - Pipeliner (server side) is the promise table: it resolves Target
// before dispatch (substituting the resolved Body for the dependent
// Payload), records every OK answer under its PromiseID, and queues a
// dependent call whose Target has not resolved yet until it does.
//
// Both are transport-agnostic: they operate on Call / Response and the raw
// dispatch function (the generated Dispatch<Iface>), so the exact same
// model works over the in-process loopback Channel and over a real TCP
// connection without change. The wire envelope is unchanged — Target has
// always been field @8; NoTarget (0) is a non-pipelined call, so a
// Pipeliner and a plain dispatcher are byte-compatible on the wire and a
// non-pipelining peer (any other language runtime) interoperates by simply
// sending Target = NoTarget.
// DispatchFunc is the generated server entry point (Dispatch<Iface>): it
// decodes a Call envelope, routes by ordinal to a handler, and returns the
// response envelope bytes. A Pipeliner wraps one of these.
type DispatchFunc func(envelope []byte) ([]byte, error)
// ErrUnresolvedTarget is returned when a dependent call names a Target that
// can never resolve on this session — it was never in flight, or its
// answer already failed (a non-OK status produces no usable result to
// pipeline on). Surfaced to the client as StatusBadRequest.
var ErrUnresolvedTarget = fmt.Errorf("rpc: pipeline target never resolves")
// Pipeliner is a server-side promise table for one session (one transport
// connection). It serializes dispatch (matching a ZAP connection's strict
// FIFO handler) while resolving Target references and queuing dependents
// whose target has not yet resolved.
//
// Lifecycle: construct with NewPipeliner(dispatch); feed each inbound
// request envelope to Handle, which returns the response envelope (or holds
// the dependent until its target resolves and then returns its response).
// A Pipeliner is safe for concurrent Handle calls — a transport that reads
// frames on multiple goroutines may call it from each.
type Pipeliner struct {
dispatch DispatchFunc
mu sync.Mutex
resolved map[uint32][]byte // PromiseID -> resolved OK Body
failed map[uint32]struct{} // PromiseID -> answered non-OK (no result)
finished map[uint32]struct{} // PromiseID -> Finished (answer dropped)
waiters map[uint32][]*pendingCall // Target -> calls queued on it
}
// pendingCall is a dependent call parked until its Target resolves. done is
// closed once result is set (the dependent's own response envelope).
type pendingCall struct {
call Call
result []byte
err error
done chan struct{}
}
// NewPipeliner returns a Pipeliner that routes resolved calls through
// dispatch.
func NewPipeliner(dispatch DispatchFunc) *Pipeliner {
return &Pipeliner{
dispatch: dispatch,
resolved: make(map[uint32][]byte),
failed: make(map[uint32]struct{}),
finished: make(map[uint32]struct{}),
waiters: make(map[uint32][]*pendingCall),
}
}
// Handle processes one inbound request envelope and returns its response
// envelope. A request with Target = NoTarget dispatches straight through.
// Otherwise the request is a dependent call and its Target decides what
// happens:
//
// - resolved (OK): the resolved Body is substituted for the request's
// Payload and it dispatches immediately.
// - failed (non-OK) or Finished: refused with StatusBadRequest — the
// Target can never produce a result to pipeline on.
// - unknown: parked until a later Handle on the same Pipeliner resolves
// it (the dependent legitimately arrived before its origin), or a
// Finish on the Target refuses it.
func (p *Pipeliner) Handle(envelope []byte) ([]byte, error) {
call, err := ParseRequest(envelope)
if err != nil {
return nil, err
}
return p.handleCall(call)
}
func (p *Pipeliner) handleCall(call Call) ([]byte, error) {
if call.Target == NoTarget {
return p.dispatchAndRecord(call)
}
// Dependent call: resolve, refuse, or park under its Target.
p.mu.Lock()
if body, ok := p.resolved[call.Target]; ok {
p.mu.Unlock()
call.Payload = body
return p.dispatchAndRecord(call)
}
// A Target that answered non-OK, or was already Finished, can never
// resolve — refuse rather than park forever.
if _, bad := p.failed[call.Target]; bad {
p.mu.Unlock()
return BuildResponse(StatusBadRequest, call.PromiseID, nil), nil
}
if _, done := p.finished[call.Target]; done {
p.mu.Unlock()
return BuildResponse(StatusBadRequest, call.PromiseID, nil), nil
}
// Unknown Target: assume its originating call is still in flight (the
// dependent legitimately arrived first) and park until a future Handle
// resolves it. Finish on the Target wakes a parked dependent with a
// refusal so it never hangs.
pc := &pendingCall{call: call, done: make(chan struct{})}
p.waiters[call.Target] = append(p.waiters[call.Target], pc)
p.mu.Unlock()
<-pc.done
return pc.result, pc.err
}
// Finish drops the cached answer for id once the client knows no further call
// will pipeline on it (the ZAP analogue of capnp's Finish message). Calling
// Finish is optional: without it, a Pipeliner retains each OK answer for the
// session's lifetime so a dependent that arrives after its target resolves
// still finds it. A long-lived connection that pipelines heavily should Finish
// each promise it is done with to bound the table.
//
// After Finish, id is terminal: any dependent that targets it — whether
// already parked or arriving later — is refused (StatusBadRequest) rather
// than hung, since the answer is gone and will never be re-produced.
func (p *Pipeliner) Finish(id uint32) {
p.mu.Lock()
delete(p.resolved, id)
delete(p.failed, id)
p.finished[id] = struct{}{}
woken := p.waiters[id]
delete(p.waiters, id)
p.mu.Unlock()
for _, pc := range woken {
pc.result = BuildResponse(StatusBadRequest, pc.call.PromiseID, nil)
close(pc.done)
}
}
// dispatchAndRecord runs one resolved call through dispatch, records its OK
// answer under its PromiseID (so later dependents resolve), and releases any
// dependents parked on this PromiseID.
func (p *Pipeliner) dispatchAndRecord(call Call) ([]byte, error) {
respBytes, err := p.dispatch(BuildRequest(call))
if err != nil {
// A transport-level dispatch failure poisons this PromiseID: any
// dependent parked on it can never resolve.
p.poison(call.PromiseID, err)
return nil, err
}
resp, perr := ParseResponse(respBytes)
if perr != nil {
p.poison(call.PromiseID, perr)
return nil, perr
}
p.record(call.PromiseID, resp)
return respBytes, nil
}
// record stores an answer under id and wakes every dependent parked on it.
// An OK answer caches its Body (the value future dependents pipeline on); a
// non-OK answer marks id failed so its dependents are refused, not hung.
func (p *Pipeliner) record(id uint32, resp Response) {
p.mu.Lock()
var woken []*pendingCall
if resp.Status == StatusOK {
// Copy the body: respBytes aliases the dispatch buffer, but a parked
// dependent reuses it as its Payload past this call's lifetime.
body := append([]byte(nil), resp.Body...)
p.resolved[id] = body
woken = p.waiters[id]
delete(p.waiters, id)
p.mu.Unlock()
for _, pc := range woken {
pc.call.Payload = body
pc.result, pc.err = p.dispatchAndRecord(pc.call)
close(pc.done)
}
return
}
p.failed[id] = struct{}{}
woken = p.waiters[id]
delete(p.waiters, id)
p.mu.Unlock()
for _, pc := range woken {
pc.result = BuildResponse(StatusBadRequest, pc.call.PromiseID, nil)
close(pc.done)
}
}
// poison wakes every dependent parked on id with err — used when dispatch
// itself fails (not a normal non-OK answer).
func (p *Pipeliner) poison(id uint32, err error) {
p.mu.Lock()
p.failed[id] = struct{}{}
woken := p.waiters[id]
delete(p.waiters, id)
p.mu.Unlock()
for _, pc := range woken {
pc.err = err
close(pc.done)
}
}
// --- client-side origination ------------------------------------------------
// Session is the client half of pipelining: a monotonic PromiseID allocator
// scoped to one transport connection. The first call of a pipeline takes a
// fresh PromiseID via Next; a dependent call sets Target to that PromiseID,
// so the two ship back-to-back and the server's Pipeliner chains them.
//
// A Promise is a typed handle to a not-yet-resolved answer: pass it as the
// Target of the next Call instead of threading raw u32s by hand. PromiseIDs
// must be unique and non-zero within a session (0 is NoTarget); Session
// guarantees both.
type Session struct {
mu sync.Mutex
next uint32
}
// NewSession returns a Session whose first allocated PromiseID is 1.
func NewSession() *Session {
return &Session{}
}
// Promise is a handle to the answer of an in-flight call. Use its ID as the
// Target of a dependent Call to pipeline on it.
type Promise struct {
// ID is the PromiseID the originating call's answer resolves to (never
// NoTarget). A dependent call sets Target = ID.
ID uint32
}
// Next allocates a fresh, unique, non-zero PromiseID for a new call and
// returns a Promise handle to its eventual answer.
func (s *Session) Next() Promise {
s.mu.Lock()
defer s.mu.Unlock()
s.next++
if s.next == NoTarget { // wrapped past 2^32-1 back to 0 — skip it.
s.next++
}
return Promise{ID: s.next}
}
// Origin builds the originating Call of a pipeline: it carries a fresh
// PromiseID (from p) and Target = NoTarget. cap and payload are this call's
// own arguments. The returned Call is ready for BuildRequest.
func (s *Session) Origin(p Promise, method uint32, cap, payload []byte) Call {
return Call{
Method: method,
PromiseID: p.ID,
Target: NoTarget,
Cap: cap,
Payload: payload,
}
}
// Pipeline builds a dependent Call that pipelines on target's answer: it
// carries its own fresh PromiseID (from p) and Target = target.ID. The
// server substitutes target's resolved Body for this call's Payload before
// dispatch, so payload here is only the part of the request NOT supplied by
// the upstream answer (often nil — the whole input is the upstream result).
func (s *Session) Pipeline(p, target Promise, method uint32, cap, payload []byte) Call {
return Call{
Method: method,
PromiseID: p.ID,
Target: target.ID,
Cap: cap,
Payload: payload,
}
}