Preserve maintenance timeout behavior and prepare SILO 20261026

Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-10-26 22:43:51 +08:00
parent a13efd5760
commit dcc2f20ba8
34 changed files with 634 additions and 969 deletions
+2 -4
View File
@@ -344,8 +344,7 @@ func (c *Subroute) newMuxClient(ctx context.Context) (*muxClient, error) {
// Request allows to do a single remote request.
// 'req' will not be used after the call and caller can reuse.
// If no deadline is set on ctx, a 1-minute deadline will be added unless
// WithoutDefaultTimeout was requested.
// If no deadline is set on ctx, a 1-minute deadline will be added.
func (c *Connection) Request(ctx context.Context, h HandlerID, req []byte) ([]byte, error) {
if !h.valid() {
return nil, ErrUnknownHandler
@@ -371,8 +370,7 @@ func (c *Connection) Request(ctx context.Context, h HandlerID, req []byte) ([]by
// Request allows to do a single remote request.
// 'req' will not be used after the call and caller can reuse.
// If no deadline is set on ctx, a 1-minute deadline will be added unless
// WithoutDefaultTimeout was requested.
// If no deadline is set on ctx, a 1-minute deadline will be added.
func (c *Subroute) Request(ctx context.Context, h HandlerID, req []byte) ([]byte, error) {
if !h.valid() {
return nil, ErrUnknownHandler
+1 -2
View File
@@ -550,8 +550,7 @@ type Requester interface {
// Call the remote with the request and return the response.
// The response should be returned with PutResponse when no error.
// If no deadline is set, a 1-minute deadline is added unless
// WithoutDefaultTimeout was requested.
// If no deadline is set, a 1-minute deadline is added.
func (h *SingleHandler[Req, Resp]) Call(ctx context.Context, c Requester, req Req) (resp Resp, err error) {
if c == nil {
if h.ignoreNilConn {
+2 -14
View File
@@ -59,16 +59,6 @@ type Response struct {
Err error
}
type withoutDefaultTimeoutKey struct{}
// WithoutDefaultTimeout disables the implicit single-request timeout.
// Explicit context cancellation and connection loss still end the request.
// Use this for admitted mutations that must return their actual result before
// the caller releases its lock.
func WithoutDefaultTimeout(ctx context.Context) context.Context {
return context.WithValue(ctx, withoutDefaultTimeoutKey{}, true)
}
func newMuxClient(ctx context.Context, muxID uint64, parent *Connection) *muxClient {
ctx, cancelFn := context.WithCancelCause(ctx)
return &muxClient{
@@ -111,10 +101,8 @@ func (m *muxClient) roundtrip(h HandlerID, req []byte) ([]byte, error) {
m.respMu.Unlock()
ctx := m.ctx
// Admitted storage mutations must not time out while the handler can
// still commit. A zero wire deadline also keeps their late response.
noDefaultTimeout, _ := ctx.Value(withoutDefaultTimeoutKey{}).(bool)
if msg.DeadlineMS == 0 && !noDefaultTimeout {
// Add deadline if none.
if msg.DeadlineMS == 0 {
msg.DeadlineMS = uint32(defaultSingleRequestTimeout / time.Millisecond)
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, defaultSingleRequestTimeout)
+29 -53
View File
@@ -25,67 +25,43 @@ import (
"time"
)
func TestSingleMutationWaitsBeyondDefaultTimeout(t *testing.T) {
for _, admitted := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "admitted"}[admitted], func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx := t.Context()
if admitted {
ctx = WithoutDefaultTimeout(ctx)
}
conn := &Connection{outQueue: make(chan []byte, 1)}
client := newMuxClient(ctx, 1, conn)
defer client.cancelFn(context.Canceled)
done := make(chan Response, 1)
go func() {
msg, err := client.roundtrip(handlerTest, nil)
done <- Response{Msg: msg, Err: err}
}()
wire := <-conn.outQueue
var request message
if _, err := request.UnmarshalMsg(wire); err != nil {
t.Fatal(err)
}
PutByteBuffer(wire)
if admitted && request.DeadlineMS != 0 {
t.Errorf("admitted mutation tells the server to discard a late result after %dms", request.DeadlineMS)
}
time.Sleep(defaultSingleRequestTimeout + time.Second)
synctest.Wait()
if !admitted {
select {
case got := <-done:
if !errors.Is(got.Err, context.DeadlineExceeded) {
t.Fatalf("ordinary timeout: %v", got.Err)
}
default:
t.Fatal("ordinary request lost its default timeout")
}
return
}
select {
case got := <-done:
t.Fatalf("mutation returned before its commit completed: %v", got.Err)
default:
}
client.response(0, Response{Msg: []byte("committed")})
got := <-done
if got.Err != nil || string(got.Msg) != "committed" {
t.Fatalf("lost late commit result: %q, %v", got.Msg, got.Err)
}
})
})
}
func TestSingleRequestDefaultTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
conn := &Connection{outQueue: make(chan []byte, 1)}
client := newMuxClient(t.Context(), 1, conn)
defer client.cancelFn(context.Canceled)
done := make(chan error, 1)
go func() { _, err := client.roundtrip(handlerTest, nil); done <- err }()
wire := <-conn.outQueue
var request message
if _, err := request.UnmarshalMsg(wire); err != nil {
t.Fatal(err)
}
PutByteBuffer(wire)
if request.DeadlineMS != uint32(defaultSingleRequestTimeout/time.Millisecond) {
t.Fatalf("wire deadline: %d", request.DeadlineMS)
}
time.Sleep(defaultSingleRequestTimeout + time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
default:
t.Fatal("request outlived the default timeout")
}
})
}
func TestWithoutDefaultTimeoutStillEndsOnDisconnectOrCancellation(t *testing.T) {
func TestSingleRequestCancellationAndDisconnect(t *testing.T) {
for _, disconnect := range []bool{false, true} {
t.Run(map[bool]string{false: "explicit-cancel", true: "disconnect"}[disconnect], func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
conn := &Connection{outQueue: make(chan []byte, 1)}
client := newMuxClient(WithoutDefaultTimeout(ctx), 1, conn)
client := newMuxClient(ctx, 1, conn)
defer client.cancelFn(context.Canceled)
done := make(chan error, 1)
go func() {
+62
View File
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Feng Ruohang
// SPDX-License-Identifier: AGPL-3.0-or-later
package ioutil
import (
"context"
"errors"
"strings"
"testing"
"testing/synctest"
"time"
)
func TestWithDeadlineReturnsBeforeWorker(t *testing.T) {
for _, cancelEarly := range []bool{false, true} {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
entered, release := make(chan struct{}), make(chan struct{})
defer close(release)
done := make(chan error, 1)
go func() {
value, err := WithDeadline(ctx, time.Second, func(context.Context) (int, error) {
close(entered)
<-release
return 42, nil
})
if value != 0 {
t.Errorf("timeout returned a late worker value: %d", value)
}
done <- err
}()
<-entered
want := context.DeadlineExceeded
if cancelEarly {
cancel()
want = context.Canceled
} else {
time.Sleep(2 * time.Second)
}
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, want) {
t.Fatalf("expected %v, got %v", want, err)
}
default:
t.Fatal("caller still waits for the blocked worker")
}
})
}
}
func TestWithDeadlineContainsPanic(t *testing.T) {
value, err := WithDeadline(t.Context(), time.Second, func(context.Context) (int, error) {
panic("deadline-test")
})
if value != 0 || err == nil || !strings.Contains(err.Error(), "deadline-test") {
t.Fatalf("panic was not converted to an error: %d, %v", value, err)
}
}
-15
View File
@@ -127,17 +127,6 @@ var deadlineWorkPanics atomic.Int64
// If the deadline/context passes before the function finishes executing,
// the zero value and the context error is returned.
func WithDeadline[V any](ctx context.Context, timeout time.Duration, work func(ctx context.Context) (result V, err error)) (result V, err error) {
return withDeadline(ctx, timeout, work, false)
}
// WithDeadlineAndWait cancels work at the deadline, but waits for it to finish
// and returns its actual result. Use it for mutations whose caller must not
// release a lock or start cleanup while the work can still change storage.
func WithDeadlineAndWait[V any](ctx context.Context, timeout time.Duration, work func(ctx context.Context) (result V, err error)) (result V, err error) {
return withDeadline(ctx, timeout, work, true)
}
func withDeadline[V any](ctx context.Context, timeout time.Duration, work func(ctx context.Context) (result V, err error), wait bool) (result V, err error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
@@ -174,10 +163,6 @@ func withDeadline[V any](ctx context.Context, timeout time.Duration, work func(c
case v := <-c:
return v.val, v.err
case <-ctx.Done():
if wait {
v := <-c
return v.val, v.err
}
var zero V
return zero, ctx.Err()
}
-53
View File
@@ -25,62 +25,9 @@ import (
"os"
"strings"
"testing"
"testing/synctest"
"time"
)
func TestWithDeadlineAndWait(t *testing.T) {
for _, deadline := range []bool{false, true} {
name := "cancel"
if deadline {
name = "deadline"
}
t.Run(name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
entered, release := make(chan struct{}), make(chan struct{})
done := make(chan ioret[int], 1)
go func() {
v, err := WithDeadlineAndWait(ctx, time.Second, func(context.Context) (int, error) {
close(entered)
<-release
return 42, nil
})
done <- ioret[int]{v, err}
}()
<-entered
if deadline {
time.Sleep(2 * time.Second)
} else {
cancel()
}
synctest.Wait()
select {
case r := <-done:
t.Fatalf("returned before mutation completed: %+v", r)
default:
}
close(release)
synctest.Wait()
r := <-done
if r.err != nil || r.val != 42 {
t.Fatalf("lost actual commit result: %+v", r)
}
})
})
}
}
func TestWithDeadlineAndWaitPanic(t *testing.T) {
_, err := WithDeadlineAndWait(t.Context(), time.Second, func(context.Context) (int, error) {
panic("mutation failure")
})
if err == nil || !strings.Contains(err.Error(), "mutation failure") {
t.Fatalf("worker panic was not returned: %v", err)
}
}
type sleepWriter struct {
timeout time.Duration
}