blob: fa31e9517551e702ebf4aa0a88bf862dcde1d939 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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 execution
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"sync"
"sync/atomic"
"github.com/vmihailenco/msgpack/v5"
)
// CoordinatorComm manages bidirectional communication with the Airflow
// supervisor over a length-prefixed msgpack socket connection.
//
// Reads are multiplexed by frame ID. A single background reader goroutine,
// lazily started on the first Communicate call, consumes inbound frames and
// dispatches each one to the Communicate caller whose request used that ID.
// This lets multiple goroutines call Communicate concurrently without
// serialising the full send-then-read round trip behind a single mutex, and
// guarantees the response a caller receives matches the request it sent.
//
// The supervisor's initial StartupDetails frame arrives unsolicited, before
// any client request is in flight, and is read synchronously via
// ReadMessage; ReadMessage must not be called after the dispatcher has been
// started.
type CoordinatorComm struct {
reader io.Reader
writer io.Writer
nextID atomic.Int64
logger *slog.Logger
wmu sync.Mutex // serialises writes
// Multiplexer state. mu protects every field below.
mu sync.Mutex
pending map[int64]chan frameResult
started bool
readErr error
}
// frameResult is the value the dispatcher delivers to a Communicate caller.
type frameResult struct {
frame IncomingFrame
err error
}
// ErrDispatcherClosed is wrapped into the error Communicate returns once the
// background reader goroutine has exited — typically because the supervisor
// closed the comm socket.
var ErrDispatcherClosed = errors.New("coordinator comm: dispatcher closed")
// NewCoordinatorComm creates a new communication channel.
func NewCoordinatorComm(reader io.Reader, writer io.Writer, logger *slog.Logger) *CoordinatorComm {
return &CoordinatorComm{
reader: reader,
writer: writer,
logger: logger,
pending: make(map[int64]chan frameResult),
}
}
// ReadMessage reads and decodes one frame directly from the comm socket.
// It is used to read the supervisor's initial frame before any request/response
// traffic begins. Calling it after the dispatcher has started would race the
// reader goroutine for input bytes, so it returns an error in that case.
func (c *CoordinatorComm) ReadMessage() (IncomingFrame, error) {
c.mu.Lock()
started := c.started
c.mu.Unlock()
if started {
return IncomingFrame{}, errors.New(
"coordinator comm: ReadMessage cannot be used after the dispatcher has started",
)
}
frame, err := readFrame(c.reader)
if err != nil {
return IncomingFrame{}, fmt.Errorf("reading frame: %w", err)
}
c.logger.Debug("Received frame", "id", frame.ID)
return frame, nil
}
// SendRequest writes a request frame (2-element [id, body]) to the supervisor.
// body is any msgpack-encodable value, normally a genmodels message struct.
// Concurrent calls are serialised so frames never interleave on the wire.
func (c *CoordinatorComm) SendRequest(id int64, body any) error {
payload, err := encodeRequest(id, body)
if err != nil {
return fmt.Errorf("encoding request: %w", err)
}
c.logger.Debug("Sending request", "id", id)
c.wmu.Lock()
defer c.wmu.Unlock()
return writeFrame(c.writer, payload)
}
// Communicate sends a request and blocks until the supervisor's response with
// the matching frame ID is delivered by the dispatcher, ctx is cancelled, or
// its deadline expires. Safe to call concurrently from multiple goroutines.
//
// If the response carries an error (either as the third element of a 3-tuple
// frame or as a body whose "type" is "ErrorResponse") it is returned as an
// *APIError. If the dispatcher's read loop has terminated, the underlying read
// error is returned wrapped in ErrDispatcherClosed.
//
// On success it returns the response body's raw msgpack bytes; the caller
// decodes them into its expected genmodels result via decodeBody.
func (c *CoordinatorComm) Communicate(
ctx context.Context,
body any,
) (msgpack.RawMessage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
id := c.nextID.Add(1) - 1
ch := make(chan frameResult, 1)
// Register the waiter under the same lock the dispatcher uses, and before
// sending the request, so the dispatcher can never deliver the response
// (or a terminal read error) before this caller is ready to receive it.
c.mu.Lock()
if !c.started {
c.started = true
go c.readLoop()
}
if c.readErr != nil {
err := c.readErr
c.mu.Unlock()
return nil, fmt.Errorf("%w: %w", ErrDispatcherClosed, err)
}
c.pending[id] = ch
c.mu.Unlock()
// Run the send in a goroutine so ctx cancellation can interrupt a blocked
// write. We deliberately do not touch the underlying connection on cancel
// (no SetWriteDeadline, no Close): manipulating the stream mid-write would
// either poison future writes with a stale deadline or risk a partial
// length-prefixed frame on the wire. Instead, we let the send goroutine
// run to completion in the background. If it eventually succeeds the
// supervisor's response has no waiter and is discarded by the dispatcher;
// if it fails the dispatcher will surface the error to other callers.
sendErr := make(chan error, 1)
go func() {
sendErr <- c.SendRequest(id, body)
}()
select {
case err := <-sendErr:
if err != nil {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, err
}
case <-ctx.Done():
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, ctx.Err()
}
var result frameResult
select {
case result = <-ch:
case <-ctx.Done():
// Drop the waiter so a late supervisor reply is logged-and-discarded
// by the dispatcher rather than piling up in c.pending. The dispatcher
// may have already delivered the response into the buffered channel
// between the select and this delete; that delivery is harmless (the
// channel is buffered to 1 and the caller has already given up).
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, ctx.Err()
}
if result.err != nil {
return nil, fmt.Errorf("%w: %w", ErrDispatcherClosed, result.err)
}
frame := result.frame
// An error reply can arrive in two shapes: the third element of a 3-tuple
// response frame, or the body of a 2-tuple frame whose "type" is
// "ErrorResponse". apiErrorFromFrame picks whichever the supervisor used.
if apiErr := apiErrorFromFrame(frame); apiErr != nil {
return nil, apiErr
}
return frame.Body, nil
}
// readLoop is the dispatcher: it reads frames from the comm socket and routes
// each one to the channel registered by the matching Communicate caller. When
// readFrame returns an error (typically io.EOF on supervisor shutdown), it
// fans the error out to every pending waiter and exits; any subsequent
// Communicate call sees the error via readErr and returns immediately.
func (c *CoordinatorComm) readLoop() {
for {
frame, err := readFrame(c.reader)
if err != nil {
c.mu.Lock()
c.readErr = err
pending := c.pending
c.pending = nil
c.mu.Unlock()
for _, ch := range pending {
ch <- frameResult{err: err}
}
return
}
c.logger.Debug("Received frame", "id", frame.ID)
c.mu.Lock()
ch, ok := c.pending[frame.ID]
if ok {
delete(c.pending, frame.ID)
}
c.mu.Unlock()
if !ok {
// A waiter whose caller cancelled or timed out is deliberately
// removed by Communicate, leaving its late reply to land here. That
// is the expected deadline path, not a protocol bug, so log at Debug.
c.logger.Debug("Discarding frame with no matching waiter", "id", frame.ID)
continue
}
ch <- frameResult{frame: frame}
}
}
// APIError represents an error returned by the supervisor over the comm socket.
type APIError struct {
Err string
Detail any
}
func (e *APIError) Error() string {
if e.Detail != nil {
return fmt.Sprintf("[%s] %v", e.Err, e.Detail)
}
return e.Err
}
// Deprecated: Use APIError.
//
//lint:ignore ST1003 kept so code that type-asserts *execution.ApiError still compiles
type ApiError = APIError