Files
versitygw/rdma/rcroutes/ops_linux_test.go
T
Jihyeon Gim d30ace72ad rdma: keep reaper callbacks off sink threads
The teardown callback ran audit, metrics, and event sinks inline on
whatever thread fired it, which is the native reaper thread: one
blocking sink (a synchronous file write on a stalled filesystem)
would stall reaping for every other session. Publications now hand
off to a dedicated worker through a bounded queue; a full queue
falls back to a detached goroutine, so the callback never waits on
a sink and no record is dropped.

A denial issued while a completion owner holds the reservation
(concurrent READY re-authorization failure) no longer steals the
publication: reserved records are invisible to the denial path,
which previously produced a denial record plus the owner's success
record for one transfer.

The tracker tests now drive a recording audit sink and assert the
published record count across expiry, reserved, denied-while-
reserved, and consume-or-noop paths: one record per session.
2026-09-09 13:16:52 +09:00

286 lines
9.2 KiB
Go

// Copyright 2026 Versity Software
// Copyright 2026 Gluesys Inc. and Jihyeon Gim
// This file is 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.
//go:build linux && amd64 && cgo
package rcroutes
import (
"errors"
"sync"
"testing"
"time"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/rdma/rcserver"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3log"
)
// The publication model: the request path reserves a session
// record before any native completion call (which fires the
// teardown callback synchronously, before the call returns), the
// callback skips reserved records, and the request path publishes
// exactly once. Unreserved records are published by the callback.
func TestOpsTrackerCallbackPublishesExpiry(t *testing.T) {
tr := newOpsTracker()
tr.register("sess-1", auth.Account{Access: "ak"}, "us-east-1",
"bkt", "obj", false, time.Now())
if got := len(tr.sessions); got != 1 {
t.Fatalf("registered sessions = %d, want 1", got)
}
// The reaper path publishes for a session no READY ever
// reserved and removes the entry.
tr.onTerminal(rcserver.TerminalEvent{SessionID: "sess-1"})
if got := len(tr.sessions); got != 0 {
t.Fatalf("session survived terminal: %d", got)
}
// A second terminal (double reap) finds nothing.
tr.onTerminal(rcserver.TerminalEvent{SessionID: "sess-1"})
if got := len(tr.sessions); got != 0 {
t.Fatalf("double terminal left residue: %d", got)
}
}
func TestOpsTrackerReserveBlocksCallback(t *testing.T) {
tr := newOpsTracker()
tr.register("sess-2", auth.Account{Access: "ak"}, "us-east-1",
"bkt", "obj", true, time.Now())
// The READY path reserves before its completion call; the
// callback the call fires synchronously must skip the record.
emit := tr.reserve("sess-2")
if emit == nil {
t.Fatal("reserve returned nil for a live session")
}
tr.onTerminal(rcserver.TerminalEvent{SessionID: "sess-2"})
if got := len(tr.sessions); got != 1 {
t.Fatalf("callback consumed a reserved record: %d", got)
}
// The request path then publishes and drops the entry.
tr.publishReserved("sess-2", emit, nil, 4096)
if got := len(tr.sessions); got != 0 {
t.Fatalf("publishReserved left residue: %d", got)
}
// A second reserve of the consumed entry is nil.
if again := tr.reserve("sess-2"); again != nil {
t.Fatal("reserve succeeded for a consumed entry")
}
}
func TestOpsTrackerReserveIsExclusive(t *testing.T) {
tr := newOpsTracker()
tr.register("sess-3", auth.Account{Access: "ak"}, "us-east-1",
"bkt", "obj", false, time.Now())
if first := tr.reserve("sess-3"); first == nil {
t.Fatal("first reserve failed")
}
if second := tr.reserve("sess-3"); second != nil {
t.Fatal("double reserve succeeded")
}
}
func TestOpsTrackerFailOutcome(t *testing.T) {
tr := newOpsTracker()
tr.register("sess-4", auth.Account{Access: "ak"}, "us-east-1",
"bkt", "obj", false, time.Now())
// Consume-or-noop: present entry is consumed.
tr.failOutcome("sess-4", errors.New("x"))
if got := len(tr.sessions); got != 0 {
t.Fatalf("failOutcome left residue: %d", got)
}
// A second call after the callback already consumed is a
// silent no-op, not a double publication.
tr.failOutcome("sess-4", errors.New("y"))
}
func TestOpsTrackerUnregister(t *testing.T) {
tr := newOpsTracker()
tr.register("sess-5", auth.Account{Access: "ak"}, "us-east-1",
"bkt", "obj", false, time.Now())
tr.unregister("sess-5")
if got := len(tr.sessions); got != 0 {
t.Fatalf("unregister left entries: %d", got)
}
// The teardown callback for the unregistered session is a
// silent no-op (native side already rejected or reaped it).
tr.onTerminal(rcserver.TerminalEvent{SessionID: "sess-5"})
if got := len(tr.sessions); got != 0 {
t.Fatalf("terminal resurrected entry: %d", got)
}
}
func TestOpsTrackerUnknownSession(t *testing.T) {
tr := newOpsTracker()
// Unknown sessions and the nil tracker are silent no-ops.
var nilTracker *opsTracker
nilTracker.reserve("ghost")
nilTracker.onTerminal(rcserver.TerminalEvent{SessionID: "ghost"})
tr.reserve("ghost")
tr.onTerminal(rcserver.TerminalEvent{SessionID: "ghost"})
if got := len(tr.sessions); got != 0 {
t.Fatalf("ghost session materialized: %d", got)
}
}
func TestNormalizeSinkError(t *testing.T) {
if normalizeSinkError(nil) != nil {
t.Fatal("nil error should stay nil")
}
// Plain errors map through the route error mapping.
got := normalizeSinkError(errors.New("x"))
apiErr, ok := got.(s3err.APIError)
if !ok || apiErr.HTTPStatusCode != 500 {
t.Fatalf("plain error => %#v", got)
}
// Wrapped S3 errors are extracted to their base form so the
// audit loggers' direct assertion classifies them correctly.
wrapped := errWrapped{s3err.GetAPIError(s3err.ErrNoSuchBucket)}
got = normalizeSinkError(wrapped)
apiErr, ok = got.(s3err.APIError)
if !ok || apiErr.Code != "NoSuchBucket" || apiErr.HTTPStatusCode != 404 {
t.Fatalf("wrapped error => %#v", got)
}
}
type errWrapped struct{ s3err.S3Error }
func (errWrapped) Error() string { return "wrapped" }
func TestHttpStatusFromError(t *testing.T) {
if got := httpStatusFromError(nil); got != 200 {
t.Fatalf("nil error => %d, want 200", got)
}
if got := httpStatusFromError(errors.New("x")); got != 500 {
t.Fatalf("plain error => %d, want 500", got)
}
if got := httpStatusFromError(s3err.GetAPIError(s3err.ErrAccessDenied)); got != 403 {
t.Fatalf("access denied => %d, want 403", got)
}
// A resource-limit rejection maps to the wire status, not a
// generic 500.
if got := httpStatusFromError(rcserver.ErrLimit); got != 429 {
t.Fatalf("limit error => %d, want 429", got)
}
}
func TestExpiredErrorClassification(t *testing.T) {
// Every transfer-level failure the READY call reports as
// RC_E_WIRE publishes as the same 502 the wire response
// carries; only an unattempted session keeps the expiry code.
cases := []struct {
outcome int
code string
status int
}{
{int(rcserver.ReadyWireFail), "RdmaTransferFailed", 502},
{int(rcserver.ReadyVerifyFail), "RdmaTransferFailed", 502},
{int(rcserver.ReadyTimeout), "RdmaTransferFailed", 502},
{int(rcserver.ReadyOK), "SessionExpired", 500},
}
for _, tc := range cases {
err := expiredError(rcserver.TerminalEvent{Outcome: tc.outcome})
apiErr := normalizeSinkError(err).(s3err.APIError)
if apiErr.Code != tc.code || apiErr.HTTPStatusCode != tc.status {
t.Fatalf("outcome %d => %s/%d, want %s/%d",
tc.outcome, apiErr.Code, apiErr.HTTPStatusCode,
tc.code, tc.status)
}
}
}
// recordingLogger captures audit publications so tests can assert
// what the sinks actually received.
type recordingLogger struct {
mu sync.Mutex
logs []recLog
}
type recLog struct {
err error
bytes int64
}
func (r *recordingLogger) Log(ctx fiber.Ctx, err error, body []byte, meta s3log.LogMeta) {
r.mu.Lock()
defer r.mu.Unlock()
r.logs = append(r.logs, recLog{err: err, bytes: meta.ObjectSize})
}
func (r *recordingLogger) HangUp() error { return nil }
func (r *recordingLogger) Shutdown() error { return nil }
func (r *recordingLogger) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.logs)
}
// TestOpsTrackerPublishesExactlyOncePerSession drives the tracker
// with a recording sink and asserts the published record count:
// one per registered session no matter how the ownership played
// out (callback expiry, reserved request path, denial).
func TestOpsTrackerPublishesExactlyOncePerSession(t *testing.T) {
rl := &recordingLogger{}
tr := newOpsTracker()
tr.SetOpsServices(OpsServices{Logger: rl})
// Expiry path: callback publishes.
tr.register("s-exp", auth.Account{Access: "ak"}, "r", "b", "o", false, time.Now())
tr.onTerminal(rcserver.TerminalEvent{SessionID: "s-exp"})
// Reserved path: reserve, callback fires (skipped), the
// request path publishes.
tr.register("s-res", auth.Account{Access: "ak"}, "r", "b", "o", false, time.Now())
emit := tr.reserve("s-res")
if emit == nil {
t.Fatal("reserve failed")
}
tr.onTerminal(rcserver.TerminalEvent{SessionID: "s-res"})
tr.publishReserved("s-res", emit, nil, 128)
// Denial path while reserved: failOutcome must not steal the
// publication; the request path still owns it.
tr.register("s-den", auth.Account{Access: "ak"}, "r", "b", "o", true, time.Now())
emit2 := tr.reserve("s-den")
if emit2 == nil {
t.Fatal("reserve failed")
}
tr.failOutcome("s-den", errors.New("denied"))
tr.publishReserved("s-den", emit2, nil, 256)
// Consume-or-noop denial of an unreserved session.
tr.register("s-fail", auth.Account{Access: "ak"}, "r", "b", "o", false, time.Now())
tr.failOutcome("s-fail", errors.New("x"))
// Let the publication worker drain.
deadline := time.Now().Add(2 * time.Second)
for rl.count() < 4 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if got := rl.count(); got != 4 {
t.Fatalf("published %d records, want 4", got)
}
}