blob: b57e393793b4ed33c926c66ebb0639436d004fa6 [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 (
"bytes"
"context"
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vmihailenco/msgpack/v5"
"github.com/apache/airflow/go-sdk/pkg/execution/genmodels"
)
func TestCoordinatorCommReadMessage(t *testing.T) {
body := map[string]any{
"type": "StartupDetails",
"ti": map[string]any{
"id": "550e8400-e29b-41d4-a716-446655440000",
"task_id": "t", "dag_id": "d", "run_id": "r",
},
}
payload, err := encodeRequest(0, body)
require.NoError(t, err)
var buf bytes.Buffer
require.NoError(t, writeFrame(&buf, payload))
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(&buf, io.Discard, logger)
frame, err := comm.ReadMessage()
require.NoError(t, err)
assert.Equal(t, int64(0), frame.ID)
assert.Equal(t, "StartupDetails", peekBodyType(frame.Body))
}
func TestCoordinatorCommSendRequest(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(bytes.NewReader(nil), &buf, logger)
body := map[string]any{
"type": "GetVariable",
"key": "test_key",
}
err := comm.SendRequest(5, body)
require.NoError(t, err)
frame, err := readFrame(&buf)
require.NoError(t, err)
assert.Equal(t, int64(5), frame.ID)
sent := rawToMap(t, frame.Body)
assert.Equal(t, "GetVariable", sent["type"])
assert.Equal(t, "test_key", sent["key"])
}
func TestCoordinatorCommCommunicate(t *testing.T) {
// Prepare a response frame for the mock supervisor to "return".
responseBody := map[string]any{
"type": "VariableResult",
"key": "my_var",
"value": "my_value",
}
responsePayload, err := encodeRequest(0, responseBody)
require.NoError(t, err)
var responseBuf bytes.Buffer
require.NoError(t, writeFrame(&responseBuf, responsePayload))
var requestBuf bytes.Buffer
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(&responseBuf, &requestBuf, logger)
result, err := comm.Communicate(
context.Background(),
genmodels.GetVariable{Type: genmodels.TypeGetVariable, Key: "my_var"},
)
require.NoError(t, err)
resultMap := rawToMap(t, result)
assert.Equal(t, "VariableResult", resultMap["type"])
assert.Equal(t, "my_value", resultMap["value"])
// Verify the request was sent.
sentFrame, err := readFrame(&requestBuf)
require.NoError(t, err)
assert.Equal(t, "GetVariable", peekBodyType(sentFrame.Body))
}
func TestCoordinatorCommCommunicateError(t *testing.T) {
// Build a 3-element response frame with an error.
responsePayload := encodeResponseFrame(t, 0, nil, map[string]any{
"type": "ErrorResponse",
"error": "not_found",
"detail": map[string]any{"reason": "Variable not found"},
})
var responseBuf bytes.Buffer
require.NoError(t, writeFrame(&responseBuf, responsePayload))
var requestBuf bytes.Buffer
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(&responseBuf, &requestBuf, logger)
_, err := comm.Communicate(
context.Background(),
genmodels.GetVariable{Type: genmodels.TypeGetVariable, Key: "missing"},
)
require.Error(t, err)
apiErr, ok := err.(*APIError)
require.True(t, ok)
assert.Equal(t, "not_found", apiErr.Err)
}
func TestCoordinatorCommCommunicateBodyError(t *testing.T) {
// Error can also come in the body element of a 2-element frame.
errorBody := map[string]any{
"type": "ErrorResponse",
"error": "server_error",
"detail": map[string]any{"reason": "internal failure"},
}
responsePayload, err := encodeRequest(0, errorBody)
require.NoError(t, err)
var responseBuf bytes.Buffer
require.NoError(t, writeFrame(&responseBuf, responsePayload))
var requestBuf bytes.Buffer
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(&responseBuf, &requestBuf, logger)
_, err = comm.Communicate(
context.Background(),
genmodels.GetVariable{Type: genmodels.TypeGetVariable, Key: "test"},
)
require.Error(t, err)
apiErr, ok := err.(*APIError)
require.True(t, ok)
assert.Equal(t, "server_error", apiErr.Err)
}
func TestAPIErrorFormat(t *testing.T) {
err := &APIError{Err: "not_found", Detail: "Variable 'x' not found"}
assert.Equal(t, "[not_found] Variable 'x' not found", err.Error())
err2 := &APIError{Err: "server_error"}
assert.Equal(t, "server_error", err2.Error())
}
// encodeResponseFrame encodes a 3-element response frame for testing.
func encodeResponseFrame(t *testing.T, id int64, body, errBody map[string]any) []byte {
t.Helper()
var buf bytes.Buffer
enc := msgpack.NewEncoder(&buf)
enc.UseCompactInts(true)
require.NoError(t, enc.EncodeArrayLen(3))
require.NoError(t, enc.EncodeInt(id))
require.NoError(t, enc.Encode(body))
require.NoError(t, enc.Encode(errBody))
return buf.Bytes()
}
// TestCoordinatorCommCommunicateConcurrentOutOfOrder exercises the dispatcher:
// it fires Communicate from N goroutines concurrently, then has a mock
// supervisor respond in reverse order. Each caller must receive the response
// whose ID matches the request it sent, even though responses arrive in a
// different order than requests.
func TestCoordinatorCommCommunicateConcurrentOutOfOrder(t *testing.T) {
const N = 8
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqR.Close()
reqW.Close()
respR.Close()
respW.Close()
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(respR, reqW, logger)
// Mock supervisor: read N request frames, then write the responses back in
// reverse order. Each response echoes the request id so the client can
// verify the response matches the request.
supervisorDone := make(chan error, 1)
go func() {
ids := make([]int64, 0, N)
for i := 0; i < N; i++ {
frame, err := readFrame(reqR)
if err != nil {
supervisorDone <- err
return
}
ids = append(ids, frame.ID)
}
for i := len(ids) - 1; i >= 0; i-- {
payload, err := encodeRequest(ids[i], map[string]any{
"type": "VariableResult",
"key": "k",
"echo": ids[i],
})
if err != nil {
supervisorDone <- err
return
}
if err := writeFrame(respW, payload); err != nil {
supervisorDone <- err
return
}
}
supervisorDone <- nil
}()
var wg sync.WaitGroup
errCh := make(chan error, N)
echoCh := make(chan int, N)
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := comm.Communicate(
context.Background(),
map[string]any{"type": "GetVariable"},
)
if err != nil {
errCh <- err
return
}
// The "echo" field carries the request id the supervisor used; the
// dispatcher must have routed each caller to its own response.
var m map[string]any
if err := msgpack.Unmarshal(resp, &m); err != nil {
errCh <- err
return
}
echoCh <- ifaceInt(m["echo"], -1)
}()
}
wg.Wait()
close(errCh)
close(echoCh)
require.NoError(t, <-supervisorDone)
for err := range errCh {
require.NoError(t, err)
}
// Each caller must have received exactly one of the N supervisor responses,
// with no duplicates. A broken dispatcher that delivered the same response
// to two callers would either show up as a missing id here or, in the worst
// case, as a deadlock that fails the test by timeout.
seen := make(map[int]bool, N)
for echo := range echoCh {
require.False(
t,
seen[echo],
"duplicate echo id %d — dispatcher routed one response to multiple callers",
echo,
)
seen[echo] = true
}
require.Len(t, seen, N, "expected %d distinct echo ids, got %d", N, len(seen))
for i := 0; i < N; i++ {
require.True(t, seen[i], "missing echo id %d from supervisor responses", i)
}
}
// TestCoordinatorCommCommunicateResponseIDMatch verifies that a caller blocks
// on its own ID even when the supervisor delivers another caller's response
// first. The single-mutex baseline would have returned the wrong response
// here.
func TestCoordinatorCommCommunicateResponseIDMatch(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqR.Close()
reqW.Close()
respR.Close()
respW.Close()
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(respR, reqW, logger)
// The supervisor reads exactly two request frames and reports each id back
// through reqIDs. A successful readFrame is proof the caller already
// registered its waiter (Communicate registers before it calls
// SendRequest), so the test can sequence id allocation deterministically
// instead of relying on sleeps.
reqIDs := make(chan int64, 2)
go func() {
for i := 0; i < 2; i++ {
frame, err := readFrame(reqR)
if err != nil {
return
}
reqIDs <- frame.ID
}
}()
results := make(chan msgpack.RawMessage, 2)
go func() {
resp, err := comm.Communicate(
context.Background(),
map[string]any{"type": "GetVariable", "key": "first"},
)
require.NoError(t, err)
results <- resp
}()
firstID := <-reqIDs
go func() {
resp, err := comm.Communicate(
context.Background(),
map[string]any{"type": "GetVariable", "key": "second"},
)
require.NoError(t, err)
results <- resp
}()
secondID := <-reqIDs
// Reply to the SECOND caller first. The first caller must keep waiting;
// only the second caller unblocks.
payload, err := encodeRequest(secondID, map[string]any{
"type": "VariableResult",
"key": "second",
})
require.NoError(t, err)
require.NoError(t, writeFrame(respW, payload))
select {
case resp := <-results:
assert.Equal(t, "second", rawToMap(t, resp)["key"])
case <-time.After(time.Second):
t.Fatal("second caller did not receive its response")
}
// First caller must still be blocked.
select {
case <-results:
t.Fatal("first caller unblocked before its response was delivered")
case <-time.After(50 * time.Millisecond):
}
// Now reply to the first caller.
payload, err = encodeRequest(firstID, map[string]any{
"type": "VariableResult",
"key": "first",
})
require.NoError(t, err)
require.NoError(t, writeFrame(respW, payload))
select {
case resp := <-results:
assert.Equal(t, "first", rawToMap(t, resp)["key"])
case <-time.After(time.Second):
t.Fatal("first caller did not receive its response")
}
}
// TestCoordinatorCommCommunicateDispatcherClosed verifies that a Communicate
// caller blocked on a response returns wrapped ErrDispatcherClosed when the
// reader side of the connection is closed without delivering a response.
func TestCoordinatorCommCommunicateDispatcherClosed(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqR.Close()
reqW.Close()
respR.Close()
respW.Close()
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(respR, reqW, logger)
// registered fires once the supervisor has read the caller's request
// frame, which guarantees Communicate has already registered its waiter
// (registration precedes SendRequest).
registered := make(chan struct{})
go func() {
if _, err := readFrame(reqR); err == nil {
close(registered)
}
for {
if _, err := readFrame(reqR); err != nil {
return
}
}
}()
errCh := make(chan error, 1)
go func() {
_, err := comm.Communicate(context.Background(), map[string]any{"type": "GetVariable"})
errCh <- err
}()
<-registered
// Close the response stream; the dispatcher's read returns io.EOF (or
// io.ErrClosedPipe) and must fan that error out to the pending waiter.
require.NoError(t, respW.Close())
select {
case err := <-errCh:
require.Error(t, err)
assert.ErrorIs(t, err, ErrDispatcherClosed)
case <-time.After(time.Second):
t.Fatal("Communicate did not return after dispatcher closed")
}
// A subsequent Communicate must short-circuit on the cached read error.
_, err := comm.Communicate(context.Background(), map[string]any{"type": "GetVariable"})
require.Error(t, err)
assert.ErrorIs(t, err, ErrDispatcherClosed)
}
// TestCoordinatorCommReadMessageAfterDispatcherStarted ensures we surface a
// clear error instead of silently racing the dispatcher for input bytes.
func TestCoordinatorCommReadMessageAfterDispatcherStarted(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqR.Close()
reqW.Close()
respR.Close()
respW.Close()
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(respR, reqW, logger)
// Signal once Communicate's request has reached the wire; by then the
// dispatcher goroutine has been started (Communicate starts it before
// SendRequest under the same lock).
dispatcherUp := make(chan struct{})
go func() {
if _, err := readFrame(reqR); err == nil {
close(dispatcherUp)
}
for {
if _, err := readFrame(reqR); err != nil {
return
}
}
}()
// First Communicate starts the dispatcher (lazily).
go func() {
_, _ = comm.Communicate(context.Background(), map[string]any{"type": "GetVariable"})
}()
<-dispatcherUp
_, err := comm.ReadMessage()
require.Error(t, err)
assert.Contains(
t,
err.Error(),
"coordinator comm: ReadMessage cannot be used after the dispatcher has started",
)
}
// TestCoordinatorCommUnknownIDDiscarded verifies that a frame whose ID does
// not match any pending waiter is logged and skipped rather than confusing the
// next caller.
func TestCoordinatorCommUnknownIDDiscarded(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqR.Close()
reqW.Close()
respR.Close()
respW.Close()
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(respR, reqW, logger)
// Report the caller's allocated request id as soon as the supervisor sees
// its frame on the wire. By then Communicate has registered its waiter,
// so we can safely send the stray frame and then the matching response.
reqIDs := make(chan int64, 1)
go func() {
if frame, err := readFrame(reqR); err == nil {
reqIDs <- frame.ID
}
for {
if _, err := readFrame(reqR); err != nil {
return
}
}
}()
// Inject a stray frame with an ID nobody is waiting on. The dispatcher
// must read this without crashing or corrupting state.
stray, err := encodeRequest(999, map[string]any{"type": "Junk"})
require.NoError(t, err)
errCh := make(chan error, 1)
respCh := make(chan msgpack.RawMessage, 1)
go func() {
resp, err := comm.Communicate(context.Background(), map[string]any{"type": "GetVariable"})
if err != nil {
errCh <- err
return
}
respCh <- resp
}()
id := <-reqIDs
require.NoError(t, writeFrame(respW, stray))
// Now send the real response.
payload, err := encodeRequest(id, map[string]any{
"type": "VariableResult",
"key": "ok",
})
require.NoError(t, err)
require.NoError(t, writeFrame(respW, payload))
select {
case resp := <-respCh:
assert.Equal(t, "ok", rawToMap(t, resp)["key"])
case err := <-errCh:
t.Fatalf("Communicate returned error: %v", err)
case <-time.After(time.Second):
t.Fatal("Communicate did not return")
}
}
// failingWriter is an io.Writer that always returns the same error. Used by
// TestCoordinatorCommSendRequestErrorCleansPending to force SendRequest to
// fail after a Communicate caller has already registered its waiter.
type failingWriter struct {
err error
}
func (w *failingWriter) Write(_ []byte) (int, error) {
return 0, w.err
}
// TestCoordinatorCommSendRequestErrorCleansPending verifies that when
// SendRequest fails (e.g. the underlying socket has died), Communicate removes
// the waiter it registered before sending. If the cleanup path were ever
// dropped, the entry would leak in c.pending and a stale supervisor reply
// arriving on that id would silently land on a channel nobody is reading.
func TestCoordinatorCommSendRequestErrorCleansPending(t *testing.T) {
// The reader side is a never-fed pipe; the dispatcher started by
// Communicate parks on readFrame until t.Cleanup closes it.
respR, respW := io.Pipe()
t.Cleanup(func() {
respR.Close()
respW.Close()
})
writeErr := errors.New("simulated write failure")
comm := NewCoordinatorComm(
respR,
&failingWriter{err: writeErr},
slog.New(slog.NewTextHandler(io.Discard, nil)),
)
_, err := comm.Communicate(context.Background(), map[string]any{"type": "GetVariable"})
require.Error(t, err)
assert.ErrorIs(t, err, writeErr)
comm.mu.Lock()
pendingLen := len(comm.pending)
comm.mu.Unlock()
assert.Equal(
t,
0,
pendingLen,
"pending map should be empty after SendRequest failure, got %d entries",
pendingLen,
)
}
// TestCoordinatorCommCommunicateContextCancelled verifies that a Communicate
// caller whose context is cancelled while waiting for a response returns
// promptly with the context error and removes its entry from c.pending so a
// late supervisor reply lands in the dispatcher's "no matching waiter" path
// instead of leaking memory.
func TestCoordinatorCommCommunicateContextCancelled(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqR.Close()
reqW.Close()
respR.Close()
respW.Close()
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(respR, reqW, logger)
// Drain the request side so SendRequest does not block; signal once the
// caller's request frame has reached the wire so we know its waiter is
// registered before we cancel ctx.
registered := make(chan struct{})
go func() {
if _, err := readFrame(reqR); err == nil {
close(registered)
}
for {
if _, err := readFrame(reqR); err != nil {
return
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, err := comm.Communicate(ctx, map[string]any{"type": "GetVariable"})
errCh <- err
}()
<-registered
cancel()
select {
case err := <-errCh:
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
t.Fatal("Communicate did not return after ctx was cancelled")
}
comm.mu.Lock()
pendingLen := len(comm.pending)
comm.mu.Unlock()
assert.Equal(
t,
0,
pendingLen,
"pending map should be empty after ctx cancellation, got %d entries",
pendingLen,
)
}
// TestCoordinatorCommCommunicateCancelDuringSend verifies that Communicate
// returns when ctx is cancelled while the request write is still blocked.
// The caller must escape with ctx.Err() and its waiter must be cleaned up;
// the send goroutine is allowed to remain parked on the wedged writer (it
// would otherwise corrupt the stream if force-unblocked mid-frame).
func TestCoordinatorCommCommunicateCancelDuringSend(t *testing.T) {
respR, respW := io.Pipe()
t.Cleanup(func() {
respR.Close()
respW.Close()
})
writer := newBlockingWriter()
t.Cleanup(writer.release)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(respR, writer, logger)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, err := comm.Communicate(ctx, map[string]any{"type": "GetVariable"})
errCh <- err
}()
// Wait until the goroutine has actually entered the blocked Write so the
// cancel below targets the in-flight send, not the pre-write ctx check.
writer.waitForWrite(t)
cancel()
select {
case err := <-errCh:
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
t.Fatal("Communicate did not return while the request write was blocked")
}
comm.mu.Lock()
pendingLen := len(comm.pending)
comm.mu.Unlock()
assert.Equal(t, 0, pendingLen,
"pending map should be empty after ctx cancellation during send")
}
// blockingWriter blocks every Write call until release() is called. It is used
// to simulate a stalled coordinator socket so a cancelled Communicate has to
// unblock the caller via context handling rather than the writer completing.
type blockingWriter struct {
once sync.Once
release func()
gate chan struct{}
entered chan struct{}
}
func newBlockingWriter() *blockingWriter {
w := &blockingWriter{
gate: make(chan struct{}),
entered: make(chan struct{}),
}
w.release = func() {
w.once.Do(func() { close(w.gate) })
}
return w
}
func (w *blockingWriter) Write(p []byte) (int, error) {
select {
case <-w.entered:
default:
close(w.entered)
}
<-w.gate
return len(p), nil
}
func (w *blockingWriter) waitForWrite(t *testing.T) {
t.Helper()
select {
case <-w.entered:
case <-time.After(time.Second):
t.Fatal("blockingWriter.Write was never called")
}
}
// TestCoordinatorCommCommunicateContextAlreadyDone verifies that a Communicate
// call whose context is already cancelled returns immediately without sending
// a request frame or starting the dispatcher.
func TestCoordinatorCommCommunicateContextAlreadyDone(t *testing.T) {
var requestBuf bytes.Buffer
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
comm := NewCoordinatorComm(bytes.NewReader(nil), &requestBuf, logger)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := comm.Communicate(ctx, map[string]any{"type": "GetVariable"})
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
assert.Equal(t, 0, requestBuf.Len(), "no request frame should have been sent")
}