mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-22 02:27:57 +00:00
The wait was one select over done and ctx.Done(). Both can be ready when it runs, select picks at random among ready cases, and so a queue that drained in the same instant the budget expired was reported as an overrun about half the times it landed there - often enough to be read as noise, and pointing at the wrong thing when it was not. core's own RunShutdown re-checks for this reason; this did not. The tie-break is now a function taking channels rather than a queue, which is what lets a test hand it a closed done and an expired ctx together. That state is the whole of the bug and cannot be arrived at reliably from the outside; over 1000 iterations the single-select version fails, and the second look does not. The test for giving up on the deadline read the call counter straight after shutdownQueue returned, while Shutdown runs on a goroutine nobody joins. It passed because the goroutine is scheduled promptly, not because anything ordered the two. The fake now signals that Shutdown has been entered and the test waits for it. Both raised by Copilot on #918. The first attempt at the tie-break test was wrong and is not what landed: it asserted that an immediately-returning Shutdown always counts as drained under an already-expired context, which is not true and should not be - if the goroutine has not run, nothing has drained. That test failed, correctly. What is being claimed is narrower: when both are ready, done wins.
334 lines
11 KiB
Go
334 lines
11 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
|
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
|
"github.com/go-admin-team/go-admin-core/v2/sdk/runtime"
|
|
corestorage "github.com/go-admin-team/go-admin-core/v2/storage"
|
|
)
|
|
|
|
// countingQueue stands in for an installed adapter. Only Shutdown is exercised
|
|
// - the drain callback never publishes or consumes - so the rest of
|
|
// AdapterQueue is deliberately absent: `installed` is typed on Shutdown alone,
|
|
// and widening the fake would only invite it to be used for something else.
|
|
type countingQueue struct {
|
|
calls atomic.Int32
|
|
block chan struct{}
|
|
|
|
// started is closed on the way into Shutdown, so a test can wait for the
|
|
// call rather than assume the goroutine that makes it was scheduled. The
|
|
// caller returns on its own deadline while Shutdown is still running, so
|
|
// reading calls straight after that return is a race with the increment.
|
|
startOnce sync.Once
|
|
started chan struct{}
|
|
}
|
|
|
|
func newCountingQueue() *countingQueue {
|
|
return &countingQueue{started: make(chan struct{})}
|
|
}
|
|
|
|
func (q *countingQueue) Shutdown() {
|
|
q.startOnce.Do(func() { close(q.started) })
|
|
q.calls.Add(1)
|
|
if q.block != nil {
|
|
<-q.block
|
|
}
|
|
}
|
|
|
|
// waitStarted blocks until Shutdown has been entered, or fails the test.
|
|
func (q *countingQueue) waitStarted(t *testing.T) {
|
|
t.Helper()
|
|
select {
|
|
case <-q.started:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Shutdown was never called")
|
|
}
|
|
}
|
|
|
|
// isolate gives the test its own runtime and its own view of what this package
|
|
// has installed, and puts the process-wide state back afterwards.
|
|
//
|
|
// Same isolation as TestSetupBumpsTheQueueGenerationOnEveryReload, plus
|
|
// drainRegistered: it is what stops a reload registering a second callback, so
|
|
// leaving it set would make every later test in this binary see a package that
|
|
// has already registered.
|
|
func isolate(t *testing.T) {
|
|
t.Helper()
|
|
|
|
prevQ, prevC := config.QueueConfig, config.CacheConfig
|
|
prevRuntime := sdk.Runtime
|
|
queueMu.Lock()
|
|
prevInstalled, prevGen, prevRegistered := installed, installedGen, drainRegistered
|
|
queueMu.Unlock()
|
|
|
|
t.Cleanup(func() {
|
|
config.QueueConfig, config.CacheConfig = prevQ, prevC
|
|
sdk.Runtime = prevRuntime
|
|
queueMu.Lock()
|
|
installed, installedGen, drainRegistered = prevInstalled, prevGen, prevRegistered
|
|
queueMu.Unlock()
|
|
})
|
|
|
|
sdk.Runtime = runtime.NewConfig()
|
|
queueMu.Lock()
|
|
installed, drainRegistered = nil, false
|
|
queueMu.Unlock()
|
|
}
|
|
|
|
// setInstalled puts a fake where setupQueue would have left the real adapter.
|
|
//
|
|
// Legitimate because the callback reads `installed` when it runs rather than
|
|
// capturing it at registration - that is the property that lets a reload
|
|
// replace the adapter and still have the right one drained.
|
|
func setInstalled(q interface{ Shutdown() }) {
|
|
queueMu.Lock()
|
|
installed = q
|
|
queueMu.Unlock()
|
|
}
|
|
|
|
func currentInstalled() interface{ Shutdown() } {
|
|
queueMu.Lock()
|
|
defer queueMu.Unlock()
|
|
return installed
|
|
}
|
|
|
|
// Issue #911: nothing shut the queue down at exit, so whatever was buffered
|
|
// went with the process.
|
|
//
|
|
// This is the half that matters most - a callback is on BeforeExit and it
|
|
// reaches the adapter this package installed. It says nothing about how many
|
|
// times the callback was registered; see the test below for that.
|
|
func TestSetupPutsTheQueueDrainOnBeforeExit(t *testing.T) {
|
|
isolate(t)
|
|
config.CacheConfig = &config.Cache{Memory: struct{}{}}
|
|
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 10}}
|
|
|
|
Setup()
|
|
Setup()
|
|
Setup()
|
|
|
|
q := newCountingQueue()
|
|
setInstalled(q)
|
|
|
|
if err := sdk.Runtime.RunShutdown(context.Background()); err != nil {
|
|
t.Fatalf("RunShutdown: %v", err)
|
|
}
|
|
|
|
if got := q.calls.Load(); got != 1 {
|
|
t.Errorf("Shutdown called %d times, want 1 - 0 means nothing registered the drain", got)
|
|
}
|
|
}
|
|
|
|
// Setup is re-run on every configuration change, so registering from it has to
|
|
// be guarded: a callback per reload would leave the shutdown phase holding a
|
|
// row of identical entries, each timed and each eligible to be named as the one
|
|
// that overran the budget.
|
|
//
|
|
// Counted at the seam rather than through the effect. The test above cannot see
|
|
// this - shutdownQueue takes the adapter on its first run, so the second and
|
|
// third callbacks find nothing and return, and three registrations produce
|
|
// exactly the same observable result as one. That is a good property of the
|
|
// callback and a blind spot for any test that goes through it.
|
|
func TestSetupRegistersTheDrainOncePerProcessHoweverManyReloads(t *testing.T) {
|
|
isolate(t)
|
|
|
|
previous := setShutdown
|
|
t.Cleanup(func() { setShutdown = previous })
|
|
registrations := 0
|
|
setShutdown = func(func(context.Context)) { registrations++ }
|
|
|
|
config.CacheConfig = &config.Cache{Memory: struct{}{}}
|
|
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 10}}
|
|
|
|
Setup()
|
|
Setup()
|
|
Setup()
|
|
|
|
if registrations != 1 {
|
|
t.Errorf("three reloads registered the drain %d times, want 1", registrations)
|
|
}
|
|
}
|
|
|
|
// The drain reaches the adapter that is current when the signal arrives, not
|
|
// one captured while wiring up. A reload replaces the adapter, and draining the
|
|
// one that was installed at start-up would drain something nobody has published
|
|
// to since.
|
|
func TestTheDrainRunsAgainstTheAdapterInstalledLast(t *testing.T) {
|
|
isolate(t)
|
|
config.CacheConfig = &config.Cache{Memory: struct{}{}}
|
|
config.QueueConfig = &config.Queue{Memory: &config.QueueMemory{PoolSize: 10}}
|
|
Setup()
|
|
|
|
first, second := newCountingQueue(), newCountingQueue()
|
|
setInstalled(first)
|
|
setInstalled(second)
|
|
|
|
if err := sdk.Runtime.RunShutdown(context.Background()); err != nil {
|
|
t.Fatalf("RunShutdown: %v", err)
|
|
}
|
|
|
|
if first.calls.Load() != 0 {
|
|
t.Error("the adapter that was replaced was shut down; the callback captured it instead of " +
|
|
"reading it when it ran")
|
|
}
|
|
if second.calls.Load() != 1 {
|
|
t.Errorf("the current adapter was shut down %d times, want 1", second.calls.Load())
|
|
}
|
|
}
|
|
|
|
// Nothing installed is the shipped default: settings.yml has no queue section,
|
|
// so setupQueue returns early and the runtime's own fallback queue is what
|
|
// callers get. Shutting that down would close a queue this package neither
|
|
// built nor started.
|
|
func TestTheDrainDoesNothingWhenThisPackageInstalledNothing(t *testing.T) {
|
|
isolate(t)
|
|
config.CacheConfig = &config.Cache{Memory: struct{}{}}
|
|
config.QueueConfig = &config.Queue{}
|
|
Setup()
|
|
|
|
if currentInstalled() != nil {
|
|
t.Fatal("an empty queue configuration installed an adapter, so this test asserts nothing")
|
|
}
|
|
if err := sdk.Runtime.RunShutdown(context.Background()); err != nil {
|
|
t.Fatalf("RunShutdown: %v", err)
|
|
}
|
|
}
|
|
|
|
// The budget bounds the wait, not the work. A consumer that never finishes must
|
|
// not hold the process past its grace period - SIGKILL would arrive mid-write
|
|
// instead of at a point of the process's choosing.
|
|
func TestTheDrainStopsWaitingWhenTheBudgetIsGone(t *testing.T) {
|
|
isolate(t)
|
|
|
|
blocked := newCountingQueue()
|
|
blocked.block = make(chan struct{})
|
|
t.Cleanup(func() { close(blocked.block) })
|
|
setInstalled(blocked)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
returned := make(chan struct{})
|
|
go func() {
|
|
defer close(returned)
|
|
shutdownQueue(ctx)
|
|
}()
|
|
|
|
select {
|
|
case <-returned:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("shutdownQueue did not return after its context expired - it waits on a Shutdown " +
|
|
"that takes no context, so the wait has to be bounded here")
|
|
}
|
|
|
|
// Waited for rather than read straight after the return: Shutdown runs on a
|
|
// goroutine that the caller does not join, so the increment is not ordered
|
|
// against the caller giving up on its deadline.
|
|
blocked.waitStarted(t)
|
|
if blocked.calls.Load() != 1 {
|
|
t.Errorf("Shutdown called %d times, want 1 - the drain has to be attempted even when it "+
|
|
"cannot be waited out", blocked.calls.Load())
|
|
}
|
|
}
|
|
|
|
// After the drain this package owns nothing. A reload arriving mid-shutdown
|
|
// then builds a new adapter rather than being handed a closed one as its
|
|
// `previous` to shut down again.
|
|
func TestTheDrainGivesUpOwnershipOfTheAdapter(t *testing.T) {
|
|
isolate(t)
|
|
setInstalled(newCountingQueue())
|
|
|
|
shutdownQueue(context.Background())
|
|
|
|
if got := currentInstalled(); got != nil {
|
|
t.Errorf("installed is %T after the drain, want nil", got)
|
|
}
|
|
}
|
|
|
|
// runtimeQueue is a full AdapterQueue, so it can be handed to the runtime
|
|
// rather than only to this package's own record of what it installed.
|
|
type runtimeQueue struct {
|
|
countingQueue
|
|
}
|
|
|
|
func (q *runtimeQueue) String() string { return "runtime-fake" }
|
|
func (q *runtimeQueue) Append(corestorage.Messager) error { return nil }
|
|
func (q *runtimeQueue) Register(string, corestorage.ConsumerFunc) {}
|
|
func (q *runtimeQueue) Run() {}
|
|
|
|
// The drain must not reach a queue this package did not install.
|
|
//
|
|
// sdk.Runtime.GetQueueAdapter never returns nil: with no queue section
|
|
// configured it wraps the runtime's own fallback, and the wrapper's Shutdown
|
|
// forwards. Reaching for the accessor would therefore look like it worked and
|
|
// would close a queue this package neither built nor started - the same shape
|
|
// as the `if q := GetQueueAdapter(); q != nil` that setupQueue already had to
|
|
// drop.
|
|
//
|
|
// The previous test's empty-configuration case cannot see this: it only checks
|
|
// that RunShutdown returns, which it would either way.
|
|
func TestTheDrainNeverReachesTheRuntimesOwnQueue(t *testing.T) {
|
|
isolate(t)
|
|
|
|
onTheRuntime := &runtimeQueue{countingQueue: *newCountingQueue()}
|
|
sdk.Runtime.SetQueueAdapter(onTheRuntime)
|
|
|
|
if currentInstalled() != nil {
|
|
t.Fatal("this package installed something, so the distinction under test is not set up")
|
|
}
|
|
if sdk.Runtime.GetQueueAdapter() == nil {
|
|
t.Fatal("the accessor returned nil, so it is no longer the trap this guards")
|
|
}
|
|
|
|
shutdownQueue(context.Background())
|
|
|
|
if got := onTheRuntime.calls.Load(); got != 0 {
|
|
t.Errorf("the runtime's queue was shut down %d times - the drain went through "+
|
|
"GetQueueAdapter instead of the adapter this package installed", got)
|
|
}
|
|
}
|
|
|
|
// A drain that finishes in the same instant the budget expires counts as
|
|
// finished.
|
|
//
|
|
// Both channels are ready when the select runs, and select picks at random
|
|
// among ready cases, so a single look reports an overrun for a drain that
|
|
// completed - roughly half the times it lands here. The repetition is what
|
|
// makes that visible: one iteration passes either way.
|
|
func TestATieBetweenTheDeadlineAndTheDrainGoesToTheDrain(t *testing.T) {
|
|
expired, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
<-expired.Done()
|
|
|
|
done := make(chan struct{})
|
|
close(done)
|
|
|
|
for i := 0; i < 1000; i++ {
|
|
if !finishedBeforeDeadline(expired, done) {
|
|
t.Fatalf("iteration %d of 1000: both the deadline and the drain were ready and the "+
|
|
"deadline won - a drain that completed is being reported as an overrun", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The other side of it. A drain that really has not finished has to be
|
|
// reported, or the warning never fires and the tie-break above has quietly
|
|
// turned into "always say it drained".
|
|
func TestADrainThatHasNotFinishedIsReportedAsAnOverrun(t *testing.T) {
|
|
expired, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
<-expired.Done()
|
|
|
|
stillRunning := make(chan struct{}) // never closed
|
|
|
|
if finishedBeforeDeadline(expired, stillRunning) {
|
|
t.Error("an unfinished drain was reported as having finished in time")
|
|
}
|
|
}
|