mirror of
https://github.com/versity/versitygw.git
synced 2026-09-27 18:34:25 +00:00
Review of the publication path found that ownership could change hands at the wrong moment and that records could disagree with both the wire response and the underlying operation. Successful transfers lost their completion record: the native completion calls fire the teardown callback synchronously, so the callback claimed the publication first and logged every completed transfer as an expiry, and committed PUTs produced no object-created events. The READY handler now reserves the publication before invoking any completion call; a reserved record is invisible to the callback, and the handler publishes the real outcome exactly once. The same race existed at creation: the PREPARE finalizer can reap an expired session and fire the callback before the session is registered, leaving an orphan entry whose only notification already happened. Registration now runs before the finalizing call, a notification that arrives first is parked and consumed by the registration, and a failed finalization drops the entry. Retained records referenced the request's pooled header buffer, so a later request could rewrite a tracked session's bucket and key; captured strings are cloned now. The synthesized publication path follows the same rule for the event senders, which serialize asynchronously. Operational sinks classify plain errors as 500 on their own, so a resource-limit rejection logged 500 while the client saw 429. The publication renders non-S3 errors through the route error mapping before the record reaches the sinks, and the expiry record carries a dedicated SessionExpired code instead of a generic one. Malformed PUT headers now preserve the operation in the record.
175 lines
5.6 KiB
Go
175 lines
5.6 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
|
|
<<<<<<< Updated upstream
|
|
// "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.
|
|
=======
|
|
// "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.
|
|
>>>>>>> Stashed changes
|
|
|
|
//go:build linux && amd64 && cgo
|
|
|
|
package rcroutes
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/versity/versitygw/auth"
|
|
"github.com/versity/versitygw/rdma/rcserver"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
// The tests exercise the tracker's ownership semantics: the session
|
|
// table is the single source of truth for who may publish, so the
|
|
// assertions watch table membership rather than the emission itself
|
|
// (emission is a no-op without operational services wired in).
|
|
|
|
func TestOpsTrackerExpiryPublication(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 claims and removes the session.
|
|
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 TestOpsTrackerRequestPathClaims(t *testing.T) {
|
|
tr := newOpsTracker()
|
|
tr.register("sess-2", auth.Account{Access: "ak"}, "us-east-1",
|
|
"bkt", "obj", true, time.Now())
|
|
|
|
// READY completion claims the publication first.
|
|
tr.publishClaimed("sess-2", nil, 4096)
|
|
if got := len(tr.sessions); got != 0 {
|
|
t.Fatalf("request path left session: %d", got)
|
|
}
|
|
|
|
// The late reaper callback finds nothing left.
|
|
tr.onTerminal(rcserver.TerminalEvent{SessionID: "sess-2"})
|
|
if got := len(tr.sessions); got != 0 {
|
|
t.Fatalf("reaper re-added session: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestOpsTrackerReserveBlocksCallback(t *testing.T) {
|
|
tr := newOpsTracker()
|
|
tr.register("sess-3", auth.Account{Access: "ak"}, "us-east-1",
|
|
"bkt", "obj", true, time.Now())
|
|
|
|
// The request path reserves before invoking a native
|
|
// completion call.
|
|
emit := tr.reserve("sess-3")
|
|
if emit == nil {
|
|
t.Fatal("reserve returned nil for a live session")
|
|
}
|
|
|
|
// The synchronous teardown callback must not publish on a
|
|
// reserved record: the entry stays put.
|
|
tr.onTerminal(rcserver.TerminalEvent{SessionID: "sess-3"})
|
|
if got := len(tr.sessions); got != 1 {
|
|
t.Fatalf("reserved session removed by callback: %d", got)
|
|
}
|
|
|
|
// A double reserve is refused.
|
|
if tr.reserve("sess-3") != nil {
|
|
t.Fatal("double reserve succeeded")
|
|
}
|
|
|
|
// The request path publishes through its reserved emitter.
|
|
tr.publishClaimed("sess-3", nil, 128)
|
|
if got := len(tr.sessions); got != 0 {
|
|
t.Fatalf("reserved session survived request publication: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestOpsTrackerEarlyTerminal(t *testing.T) {
|
|
tr := newOpsTracker()
|
|
|
|
// Teardown notification for an unregistered session parks.
|
|
tr.onTerminal(rcserver.TerminalEvent{SessionID: "sess-4"})
|
|
if got := len(tr.earlyTerminals); got != 1 {
|
|
t.Fatalf("early terminal not parked: %d", got)
|
|
}
|
|
|
|
// Registration consumes it: no live entry remains, so no
|
|
// orphan record can outlive the session.
|
|
tr.register("sess-4", auth.Account{Access: "ak"}, "us-east-1",
|
|
"bkt", "obj", false, time.Now())
|
|
if got := len(tr.earlyTerminals); got != 0 {
|
|
t.Fatalf("early terminal not consumed: %d", got)
|
|
}
|
|
if got := len(tr.sessions); got != 0 {
|
|
t.Fatalf("orphan session entry created: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestOpsTrackerUnregister(t *testing.T) {
|
|
tr := newOpsTracker()
|
|
tr.register("sess-5", auth.Account{Access: "ak"}, "us-east-1",
|
|
"bkt", "obj", false, time.Now())
|
|
tr.onTerminal(rcserver.TerminalEvent{SessionID: "sess-5"})
|
|
tr.register("sess-6", auth.Account{Access: "ak"}, "us-east-1",
|
|
"bkt", "obj", false, time.Now())
|
|
tr.unregister("sess-6")
|
|
if got := len(tr.sessions); got != 0 {
|
|
t.Fatalf("unregister left entries: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestOpsTrackerUnknownSession(t *testing.T) {
|
|
tr := newOpsTracker()
|
|
// Unknown sessions and the nil tracker are silent no-ops.
|
|
var nilTracker *opsTracker
|
|
nilTracker.publishClaimed("ghost", nil, 1)
|
|
nilTracker.onTerminal(rcserver.TerminalEvent{SessionID: "ghost"})
|
|
tr.publishClaimed("ghost", nil, 1)
|
|
tr.onTerminal(rcserver.TerminalEvent{SessionID: "ghost"})
|
|
if got := len(tr.sessions); got != 0 {
|
|
t.Fatalf("ghost session materialized: %d", got)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|