blob: ca05f7e04f80274d86544e730854d97d43ab8aef [file] [edit]
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package rsproxy
import (
"context"
"fmt"
"net"
"sync"
"time"
"github.com/bazelbuild/rsclient/internal/pkg/auth"
"github.com/bazelbuild/rsclient/internal/pkg/fifo"
"github.com/bazelbuild/rsclient/internal/pkg/syncutil"
"github.com/golang/glog"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// RPCTimeouts is a map that defines custom timeouts for specific RPC operations.
type RPCTimeouts map[string]time.Duration
// DefaultRPCTimeouts provides conservative default timeouts for backend RPC calls.
var DefaultRPCTimeouts = map[string]time.Duration{
"default": 20 * time.Second,
}
// ProxyEgressParams holds the gRPC connections required to initialize a Proxy's egress workers.
type ProxyEgressParams struct {
RSConn *grpc.ClientConn
CASConn *grpc.ClientConn
JSConn *grpc.ClientConn
}
// ProxyConfig defines the complete configuration for a Proxy and its workers.
type ProxyConfig struct {
ResultStoreEgressOptions
JobStatusEgressOptions
PrototextWriterOptions
StreamIngressOptions
GRPCIngressOptions
PrototextReaderOptions
// IngressListener is an optional existing listener for the gRPC ingress.
// Intended for testing or specialized embedding as a dependency injection point.
IngressListener net.Listener
// RSConn, CASConn, and JSConn are optional existing connections (e.g. for testing).
// If set, these take precedence over dialing new connections from RSDialParams, etc.
RSConn *grpc.ClientConn
CASConn *grpc.ClientConn
JSConn *grpc.ClientConn
// RPCTimeouts sets deadlines for individual RPC calls.
RPCTimeouts RPCTimeouts
// Retrier is the policy used for retrying transient RPC failures.
Retrier *Retrier
}
// HasGRPCIngress returns true if a gRPC ingress worker is configured.
func (c *ProxyConfig) HasGRPCIngress() bool {
return c.GRPCIngressOptions.Addr != "" || c.IngressListener != nil
}
// Proxy is a high-level orchestrator for communicating with multiple backend
// services (e.g. ResultStore, CAS, JobStatus) and managing the build event ingress.
//
// It manages the lifecycle of specialized egress workers and uses a Router to
// fan out incoming build events from various ingress sources (e.g. FIFO, gRPC).
//
// The Proxy acts as the central coordinator for the entire build event pipeline,
// ensuring that ingress sources are properly opened, events are correctly
// routed to all active egress workers, and that the backend invocation is
// gracefully finalized after the build tool finishes.
type Proxy struct {
// config holds the validated configuration used to initialize the proxy.
config *ProxyConfig
// creds are common credential options used for all underlying RPC calls.
creds credentials.PerRPCCredentials
// router fans out build events to registered egress workers.
router *Router
// ingressWorker is the configured input source (e.g., FIFO, gRPC, or Prototext).
ingressWorker IngressWorker
// FinalizationSignal is closed when the client should begin processing
// post-build artifacts and finalize the backend invocation.
FinalizationSignal chan struct{}
// runStarted is closed when the Run() function has successfully started
// its background processing pipeline.
runStarted chan struct{}
runStartedOnce sync.Once
// ready provides a thread-safe way to broadcast the proxy's runtime
// metadata to multiple concurrent listeners.
ready syncutil.ReadySignal[IngressMetadata]
// cleanupFns is a list of functions to be called during shutdown to
// release resources (e.g., closing file handles, stopping background tasks).
cleanupFns []func()
// cleanupDone is closed when all cleanup functions have finished executing.
cleanupDone chan bool
// rrOnce ensures that DrainAndReleaseResources is only executed once.
rrOnce sync.Once
// fOnce ensures that SignalFinalization is only executed once.
fOnce sync.Once
}
// IngressMetadata contains information about the proxy's active ingress.
type IngressMetadata struct {
// GRPCAddr is the network address the gRPC ingress is bound to.
GRPCAddr string
// FIFOPath is the filesystem path to the FIFO/File ingress.
FIFOPath string
}
// SignalReadyFunc is a callback triggered when a component is ready to
// process traffic.
type SignalReadyFunc func(IngressMetadata)
// NewProxyFromConnection creates a Proxy from existing gRPC connections.
// It initializes and registers the ResultStore and JobStatus workers with the Router.
func NewProxyFromConnection(ctx context.Context, p ProxyEgressParams, opts ...ProxyOpt) (*Proxy, error) {
connOpt := &connectionOpt{params: p}
allOpts := make([]ProxyOpt, 0, len(opts)+1)
allOpts = append(allOpts, connOpt)
allOpts = append(allOpts, opts...)
return NewProxy(ctx, allOpts...)
}
// connectionOpt is an internal ProxyOpt used to inject existing connections into the ProxyConfig.
type connectionOpt struct {
params ProxyEgressParams
}
func (o *connectionOpt) ApplyProxy(p *Proxy) {
p.config.RSConn = o.params.RSConn
p.config.CASConn = o.params.CASConn
p.config.JSConn = o.params.JSConn
}
// ProxyOpt is an interface for configuring the Proxy during initialization.
type ProxyOpt interface {
ApplyProxy(*Proxy)
}
// ApplyProxy implements ProxyOpt for RPCTimeouts.
func (d RPCTimeouts) ApplyProxy(p *Proxy) {
p.config.RPCTimeouts = d
}
// ApplyProxy implements ProxyOpt for Retrier.
func (r *Retrier) ApplyProxy(p *Proxy) {
p.config.Retrier = r
}
// ApplyProxy implements ProxyOpt for ProxyConfig.
func (c *ProxyConfig) ApplyProxy(p *Proxy) {
// Surgically merge the provided options into the proxy's internal config.
if c.RSEgress != nil {
p.config.RSEgress = c.RSEgress
}
if c.CASEgress != nil {
p.config.CASEgress = c.CASEgress
}
if c.Conn != nil {
p.config.Conn = c.Conn
}
if c.RSInstanceName != "" {
p.config.RSInstanceName = c.RSInstanceName
}
if c.CASInstanceName != "" {
p.config.CASInstanceName = c.CASInstanceName
}
if c.PartialInvocation != nil {
p.config.PartialInvocation = c.PartialInvocation
}
if c.ResultsURL != "" {
p.config.ResultsURL = c.ResultsURL
}
if c.QuietMode {
p.config.QuietMode = true
}
if len(c.PreBuildArtifacts) > 0 {
p.config.PreBuildArtifacts = c.PreBuildArtifacts
}
if len(c.PostBuildArtifacts) > 0 {
p.config.PostBuildArtifacts = c.PostBuildArtifacts
}
if c.BundleCountThreshold != 0 {
p.config.BundleCountThreshold = c.BundleCountThreshold
}
if c.BundleByteThreshold != 0 {
p.config.BundleByteThreshold = c.BundleByteThreshold
}
if c.MangleResourceIDs {
p.config.MangleResourceIDs = true
}
if c.Passthrough {
p.config.Passthrough = true
}
if c.JobStatusAddr != "" {
p.config.JobStatusAddr = c.JobStatusAddr
}
if c.PrototextWriterOptions.Path != "" {
p.config.PrototextWriterOptions.Path = c.PrototextWriterOptions.Path
}
if c.StreamIngressOptions.InputFile != "" {
p.config.SetFile(c.StreamIngressOptions.InputFile)
} else if c.StreamIngressOptions.InputFIFO != "" {
p.config.SetFIFO(c.StreamIngressOptions.InputFIFO)
}
if c.StreamIngressOptions.IsTemp {
p.config.StreamIngressOptions.IsTemp = true
}
if c.StreamIngressOptions.OpenBlocking {
p.config.StreamIngressOptions.OpenBlocking = true
}
if c.GRPCIngressOptions.Addr != "" {
p.config.GRPCIngressOptions.Addr = c.GRPCIngressOptions.Addr
}
if c.PrototextReaderOptions.Path != "" {
p.config.PrototextReaderOptions.Path = c.PrototextReaderOptions.Path
}
if c.IngressListener != nil {
p.config.IngressListener = c.IngressListener
}
if c.RSConn != nil {
p.config.RSConn = c.RSConn
}
if c.CASConn != nil {
p.config.CASConn = c.CASConn
}
if c.JSConn != nil {
p.config.JSConn = c.JSConn
}
if c.RPCTimeouts != nil {
p.config.RPCTimeouts = c.RPCTimeouts
}
if c.Retrier != nil {
p.config.Retrier = c.Retrier
}
}
// ProxyPerRPCCredsOpt is an ProxyOpt that provides per-RPC credentials to the Proxy.
type ProxyPerRPCCredsOpt struct {
Creds credentials.PerRPCCredentials
}
// ApplyProxy implements ProxyOpt for ProxyPerRPCCredsOpt.
func (p *ProxyPerRPCCredsOpt) ApplyProxy(proxy *Proxy) {
proxy.creds = p.Creds
}
// DialParamsOpt is an ProxyOpt that provides dial parameters for ResultStore, CAS, and JobStatus.
type DialParamsOpt struct {
RSDialParams auth.DialParams
CASDialParams auth.DialParams
JSDialParams auth.DialParams
}
// ApplyProxy implements ProxyOpt for DialParamsOpt.
func (d *DialParamsOpt) ApplyProxy(p *Proxy) {
if d.RSDialParams.Service != "" {
p.config.RSEgress = auth.NewServiceConnection(d.RSDialParams)
}
if d.CASDialParams.Service != "" {
p.config.CASEgress = auth.NewServiceConnection(d.CASDialParams)
}
if d.JSDialParams.Service != "" {
p.config.Conn = auth.NewServiceConnection(d.JSDialParams)
}
}
// NewProxy creates a new Proxy and establishes connections to the ResultStore,
// CAS, and (optionally) JobStatus services based on the provided configuration.
func NewProxy(ctx context.Context, opts ...ProxyOpt) (*Proxy, error) {
proxy := &Proxy{
config: &ProxyConfig{
ResultStoreEgressOptions: ResultStoreEgressOptions{
BundleCountThreshold: DefaultBundleCountThreshold,
BundleByteThreshold: DefaultBundleByteThreshold,
},
RPCTimeouts: DefaultRPCTimeouts,
Retrier: RetryTransient(),
},
router: NewRouter(ctx),
FinalizationSignal: make(chan struct{}),
runStarted: make(chan struct{}),
cleanupDone: make(chan bool),
}
// Phase 1: Apply all options to the configuration.
for _, o := range opts {
if o != nil {
o.ApplyProxy(proxy)
}
}
// Phase 2: Initialize workers based on the final configuration.
// ResultStore Worker
if w, err := proxy.config.ResultStoreEgressOptions.CreateWorker(ctx, proxy.config.RSConn, proxy.config.CASConn, proxy.config.Retrier, proxy.RPCOpts, proxy.FinalizationSignal); err != nil {
return nil, err
} else if w != nil {
if err := proxy.router.RegisterEgressWorker(w); err != nil {
proxy.Close(ctx)
return nil, err
}
}
// JobStatus Worker
if w, err := proxy.config.JobStatusEgressOptions.CreateWorker(ctx, proxy.config.JSConn, proxy.config.Retrier, proxy.RPCOpts); err != nil {
return nil, err
} else if w != nil {
if err := proxy.router.RegisterEgressWorker(w); err != nil {
proxy.Close(ctx)
return nil, err
}
}
// PrototextWriter
if w, err := proxy.config.PrototextWriterOptions.CreateWorker(); err != nil {
return nil, err
} else if w != nil {
if err := proxy.router.RegisterEgressWorker(w); err != nil {
proxy.Close(ctx)
return nil, err
}
}
// Phase 3: Initialize Ingress Worker
if proxy.config.HasGRPCIngress() {
lis := proxy.config.IngressListener
if lis == nil && proxy.config.GRPCIngressOptions.Addr != "bufnet" {
var err error
lis, err = net.Listen("tcp", proxy.config.GRPCIngressOptions.Addr)
if err != nil {
return nil, err
}
proxy.config.GRPCIngressOptions.Addr = lis.Addr().String()
}
proxy.ingressWorker = NewGRPCIngressWorker(lis, proxy.signalIngressReady)
} else if proxy.config.PrototextReaderOptions.Path != "" {
proxy.ingressWorker = NewPrototextReader(proxy.config.PrototextReaderOptions.Path)
} else if proxy.config.StreamIngressOptions.InputFIFO != "" || proxy.config.StreamIngressOptions.InputFile != "" || proxy.config.StreamIngressOptions.IsTemp {
worker := NewStreamIngressWorker(proxy.config.StreamIngressOptions.ToInputSource(), proxy.signalIngressReady)
// Setup the source (creates it on disk and potentially blocks on open).
cleanup, err := worker.(*streamIngressWorker).Setup(ctx)
if err != nil {
proxy.Close(ctx)
return nil, fmt.Errorf("failed to setup stream ingress: %w", err)
}
// Update the path in config (handles random temp names and input-type specific fields).
path := worker.(*streamIngressWorker).Path()
if proxy.config.StreamIngressOptions.InputFile != "" {
proxy.config.StreamIngressOptions.InputFile = path
} else {
proxy.config.StreamIngressOptions.InputFIFO = path
}
proxy.ingressWorker = worker
// Signal readiness immediately since the FIFO/File is now created on disk.
proxy.signalIngressReady(IngressMetadata{FIFOPath: path})
// Ensure the source is cleaned up when the proxy is closed.
proxy.AddCleanup(cleanup)
}
return proxy, nil
}
// Close gracefully shuts down the proxy and all its registered workers.
func (p *Proxy) Close(ctx context.Context) error {
glog.Infof("proxy waiting for background tasks to finish")
select {
case <-p.runStarted:
// Run was called, wait for the router to finish.
default:
// Run was never called, do nothing.
return nil
}
glog.Infof("proxy background tasks finished")
return p.router.Close(ctx)
}
// Run executes the internal Router, which kicks off all registered workers
// in parallel. It returns when the ingress stream is finished and all workers
// have successfully completed their processing.
func (p *Proxy) Run(ctx context.Context) error {
p.runStartedOnce.Do(func() {
close(p.runStarted)
})
worker := p.ingressWorker
if worker == nil {
worker = noopIngressWorker{}
p.signalIngressReady(IngressMetadata{})
}
if err := p.router.SetIngress(worker); err != nil {
return err
}
return p.router.Run(ctx)
}
// Ready returns a channel that will receive IngressMetadata once the server
// is configured and ready to process build events. This method is
// thread-safe and supports multiple concurrent listeners (broadcast) as
// well as late-joiners.
func (p *Proxy) Ready() <-chan IngressMetadata {
return p.ready.Ready()
}
// IngressDrainedChan returns a channel that is closed when the ingress
// source has been completely consumed and drained by the dispatcher.
func (p *Proxy) IngressDrainedChan() <-chan struct{} {
return p.router.IngressDrainedChan()
}
// signalIngressReady signals that the proxy is ready to accept requests.
func (p *Proxy) signalIngressReady(m IngressMetadata) {
p.ready.Signal(m)
}
// SignalIngressEOF unblocks the proxy's ingress reader by sending a best-effort
// EOF signal. This is primarily used for FIFO ingress to ensure the reader
// loop doesn't hang if the build tool exits without ever opening the pipe.
// It is a no-op if the ingress source is not a FIFO.
func (p *Proxy) SignalIngressEOF() error {
var m IngressMetadata
if v, ok := p.ready.Value(); ok {
m = v
}
if m.FIFOPath != "" {
return fifo.SignalFIFOEOF(m.FIFOPath)
}
return nil
}
// SignalFinalization signals to all workers that the primary build phase is
// complete and they should process post-build artifacts and finalize their
// respective backend operations.
func (p *Proxy) SignalFinalization() {
p.fOnce.Do(func() {
glog.Infof("Signaling workers to finalize invocation and upload post-build artifacts.")
close(p.FinalizationSignal)
})
}
// AddCleanup adds a function to be called during DrainAndReleaseResources.
func (p *Proxy) AddCleanup(fn func()) {
p.cleanupFns = append(p.cleanupFns, fn)
}
// DrainAndReleaseResources runs all registered cleanup functions.
func (p *Proxy) DrainAndReleaseResources() {
p.rrOnce.Do(func() {
glog.Infof("Proxy shutting down.")
defer p.ready.Close()
defer func() {
glog.Infof("Proxy shutdown complete.")
close(p.cleanupDone)
}()
for _, cleanup := range p.cleanupFns {
if cleanup != nil {
cleanup()
}
}
})
}
// Shutdown initiates a graceful shutdown of the proxy, cleaning up all resources.
func (p *Proxy) Shutdown() {
p.DrainAndReleaseResources()
<-p.cleanupDone
}
// Router returns the internal Router instance managed by this proxy.
func (p *Proxy) Router() *Router {
return p.router
}
// Errors returns a snapshot of the non-fatal errors encountered
// across all active workers.
func (p *Proxy) Errors() []error {
return p.router.Errors()
}
// HasEgress returns true if at least one egress worker is configured and ready to run.
func (p *Proxy) HasEgress() bool {
return p.router.HasEgress()
}
// HasGRPCIngress returns true if a gRPC ingress worker is configured.
func (p *Proxy) HasGRPCIngress() bool {
return p.config.HasGRPCIngress()
}
// RPCOpts returns the set of default gRPC call options (like authentication)
// that should be applied to all requests made by this proxy.
func (p *Proxy) RPCOpts() []grpc.CallOption {
opts := []grpc.CallOption{}
if p.creds == nil {
return opts
}
return append(opts, grpc.PerRPCCredentials(p.creds))
}
// CallWithTimeout wraps a gRPC call with the configured timeout for that
// specific operation.
func (p *Proxy) CallWithTimeout(ctx context.Context, rpcName string, f func(ctx context.Context) error) error {
timeout, ok := p.config.RPCTimeouts[rpcName]
if !ok {
if timeout, ok = p.config.RPCTimeouts["default"]; !ok {
timeout = 0
}
}
if timeout == 0 {
return f(ctx)
}
childCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return f(childCtx)
}