Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 26 additions & 27 deletions internal/core/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package core
import (
"context"
"testing"
"testing/synctest"
"time"
)

Expand Down Expand Up @@ -219,37 +220,35 @@ func TestContextCancellation_BeforeExecute(t *testing.T) {
}
}

// TestContextTimeout tests context with timeout
// TestContextTimeout tests context with timeout.
// synctest: the deadline's cancel runs in a time.AfterFunc goroutine, so a
// Sleep-past-the-deadline check on a real clock can wake before it runs.
func TestContextTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()

// Wait for timeout
time.Sleep(50 * time.Millisecond)
time.Sleep(10 * time.Millisecond)
synctest.Wait()

if ctx.Err() == nil {
t.Error("Context should have timed out")
}

if ctx.Err() != context.DeadlineExceeded {
t.Errorf("Expected context.DeadlineExceeded, got %v", ctx.Err())
}
if ctx.Err() != context.DeadlineExceeded {
t.Errorf("Expected context.DeadlineExceeded, got %v", ctx.Err())
}
})
}

// TestContextDeadline tests context with deadline
// TestContextDeadline tests context with deadline.
func TestContextDeadline(t *testing.T) {
deadline := time.Now().Add(10 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

// Wait for deadline
time.Sleep(50 * time.Millisecond)

if ctx.Err() == nil {
t.Error("Context should have exceeded deadline")
}

if ctx.Err() != context.DeadlineExceeded {
t.Errorf("Expected context.DeadlineExceeded, got %v", ctx.Err())
}
synctest.Test(t, func(t *testing.T) {
deadline := time.Now().Add(10 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

time.Sleep(10 * time.Millisecond)
synctest.Wait()

if ctx.Err() != context.DeadlineExceeded {
t.Errorf("Expected context.DeadlineExceeded, got %v", ctx.Err())
}
})
}
53 changes: 44 additions & 9 deletions internal/core/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"testing"
"testing/synctest"
"time"

"github.com/coregx/relica/internal/logger"
Expand Down Expand Up @@ -85,19 +86,53 @@ func TestWithConnMaxIdleTime(t *testing.T) {
opt(db) // must not panic
}

// TestWithHealthCheck checks what the health checker actually does: it pings on
// every tick, records the time, and reports a failed ping as unhealthy.
// synctest's fake clock makes the ticks exact; the DB is opened inside the
// bubble so database/sql's goroutines belong to it.
func TestWithHealthCheck(t *testing.T) {
db := openCovDB(t)
defer db.Close()
synctest.Test(t, func(t *testing.T) {
const interval = 100 * time.Millisecond
db := openCovDB(t)
defer db.Close()

start := time.Now()
WithHealthCheck(interval)(db)
if db.healthChecker == nil {
t.Fatal("WithHealthCheck: expected healthChecker to be set, got nil")
}

opt := WithHealthCheck(100 * time.Millisecond)
opt(db)
synctest.Wait()
if got := db.Stats().LastHealthCheck; !got.IsZero() {
t.Fatalf("LastHealthCheck before the first tick = %v, want zero", got)
}

if db.healthChecker == nil {
t.Fatal("WithHealthCheck: expected healthChecker to be set, got nil")
}
time.Sleep(interval)
synctest.Wait()
s := db.Stats()
if want := start.Add(interval); !s.LastHealthCheck.Equal(want) {
t.Errorf("LastHealthCheck after the first tick = %v, want %v", s.LastHealthCheck, want)
}
if !s.Healthy || !db.IsHealthy() {
t.Error("expected healthy after a successful ping")
}

// Give health checker one tick to run, then stop it cleanly via Close.
time.Sleep(150 * time.Millisecond)
// Negative: the database goes away, the next tick must report it.
if err := db.sqlDB.Close(); err != nil {
t.Fatal(err)
}
time.Sleep(interval)
synctest.Wait()
if db.IsHealthy() || db.Stats().Healthy {
t.Error("expected unhealthy after the ping failed on a closed database")
}
if err := db.healthChecker.lastError(); err == nil {
t.Error("expected the failed ping's error to be recorded")
}
if want := start.Add(2 * interval); !db.Stats().LastHealthCheck.Equal(want) {
t.Errorf("LastHealthCheck after the second tick = %v, want %v", db.Stats().LastHealthCheck, want)
}
})
}

func TestWithHealthCheckZeroInterval(t *testing.T) {
Expand Down
2 changes: 0 additions & 2 deletions internal/core/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,6 @@ func (h *healthChecker) isHealthy() bool {
}

// lastError returns the error from the most recent health check.
//
//nolint:unused // May be used for debugging/monitoring
func (h *healthChecker) lastError() error {
h.mu.RLock()
defer h.mu.RUnlock()
Expand Down
77 changes: 50 additions & 27 deletions internal/util/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package util

import (
"context"
"errors"
"testing"
"testing/synctest"
"time"
)

Expand Down Expand Up @@ -35,12 +37,15 @@ func TestIsCanceled(t *testing.T) {
})

t.Run("expired deadline context is canceled", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
time.Sleep(5 * time.Millisecond)
if !IsCanceled(ctx) {
t.Error("expected true")
}
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
time.Sleep(time.Second)
synctest.Wait() // let the timer's cancel goroutine finish
if !IsCanceled(ctx) {
t.Error("expected true")
}
})
})

t.Run("context with future deadline is not canceled", func(t *testing.T) {
Expand Down Expand Up @@ -69,14 +74,33 @@ func TestWithTimeout(t *testing.T) {
}
})

t.Run("context expires after timeout", func(t *testing.T) {
ctx, cancel := WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
// The deadline is enforced by time.AfterFunc, which cancels the context from
// its own goroutine. On a real clock a test that sleeps past the deadline can
// wake before that goroutine runs (both timers fire in one scheduler pass when
// the process is descheduled), so the old Sleep-based version was flaky.
// synctest uses a fake clock, and synctest.Wait returns only after the cancel
// goroutine is done, which makes the check exact at the boundary.
t.Run("context expires exactly at the timeout", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const timeout = 5 * time.Second
ctx, cancel := WithTimeout(context.Background(), timeout)
defer cancel()

time.Sleep(5 * time.Millisecond)
if !IsCanceled(ctx) {
t.Error("expected true")
}
time.Sleep(timeout - time.Nanosecond)
synctest.Wait()
if IsCanceled(ctx) {
t.Fatalf("canceled before the timeout: %v", ctx.Err())
}

time.Sleep(time.Nanosecond)
synctest.Wait()
if !IsCanceled(ctx) {
t.Fatal("not canceled at the timeout")
}
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Errorf("ctx.Err() = %v, want %v", ctx.Err(), context.DeadlineExceeded)
}
})
})

t.Run("cancel function stops context before timeout", func(t *testing.T) {
Expand All @@ -91,21 +115,20 @@ func TestWithTimeout(t *testing.T) {
})

t.Run("deadline is set correctly", func(t *testing.T) {
timeout := 5 * time.Second
before := time.Now()
ctx, cancel := WithTimeout(context.Background(), timeout)
defer cancel()
synctest.Test(t, func(t *testing.T) {
const timeout = 5 * time.Second
start := time.Now() // the fake clock does not move until we block
ctx, cancel := WithTimeout(context.Background(), timeout)
defer cancel()

deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected true")
}
if !deadline.After(before) {
t.Error("expected true")
}
if !deadline.Before(before.Add(timeout + 100*time.Millisecond)) {
t.Error("expected true")
}
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected a deadline")
}
if want := start.Add(timeout); !deadline.Equal(want) {
t.Errorf("deadline = %v, want %v", deadline, want)
}
})
})

t.Run("inherits parent context values", func(t *testing.T) {
Expand Down
57 changes: 34 additions & 23 deletions internal/util/uuidv7_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"regexp"
"strings"
"testing"
"testing/synctest"
"time"
)

Expand Down Expand Up @@ -51,36 +52,46 @@ func TestGenerateUUIDv7_Uniqueness(t *testing.T) {
}
}

// The timestamp is wall-clock milliseconds. On a real clock "sleep, then expect
// a later millisecond" depends on the OS clock tick (15.6 ms on Windows unless
// something raised the timer resolution) and on clock adjustments. The synctest
// fake clock advances by exactly the slept duration.
func TestGenerateUUIDv7_TimeOrdering(t *testing.T) {
id1 := GenerateUUIDv7()
time.Sleep(2 * time.Millisecond)
id2 := GenerateUUIDv7()

if id2 <= id1 {
t.Errorf("later UUID must sort higher: %s should be > %s", id2, id1)
}
synctest.Test(t, func(t *testing.T) {
id1 := GenerateUUIDv7()
time.Sleep(time.Millisecond)
id2 := GenerateUUIDv7()

// Within one millisecond the order is decided by random bits, so check
// the timestamps first: a constant timestamp would otherwise pass half
// the time.
if ms1, ms2 := uuidv7Millis(t, id1), uuidv7Millis(t, id2); ms2 != ms1+1 {
t.Fatalf("timestamps %d then %d, want consecutive milliseconds", ms1, ms2)
}
if id2 <= id1 {
t.Errorf("later UUID must sort higher: %s should be > %s", id2, id1)
}
})
}

func TestGenerateUUIDv7_TimestampEmbedded(t *testing.T) {
before := time.Now().UnixMilli()
id := GenerateUUIDv7()
after := time.Now().UnixMilli()
synctest.Test(t, func(t *testing.T) {
want := time.Now().UnixMilli() // fake clock: stays put until we block
if got := uuidv7Millis(t, GenerateUUIDv7()); got != want {
t.Errorf("embedded timestamp = %d, want %d", got, want)
}
})
}

raw := strings.ReplaceAll(id, "-", "")
b, err := hex.DecodeString(raw)
// uuidv7Millis returns the 48-bit Unix millisecond timestamp of a UUIDv7.
func uuidv7Millis(t *testing.T, id string) int64 {
t.Helper()
b, err := hex.DecodeString(strings.ReplaceAll(id, "-", ""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

ms := uint64(b[0])<<40 | uint64(b[1])<<32 | uint64(b[2])<<24 |
uint64(b[3])<<16 | uint64(b[4])<<8 | uint64(b[5])

if ms < uint64(before) {
t.Errorf("expected %v >= %v", ms, uint64(before))
}
if ms > uint64(after) {
t.Errorf("expected %v <= %v", ms, uint64(after))
t.Fatalf("decode %q: %v", id, err)
}
return int64(b[0])<<40 | int64(b[1])<<32 | int64(b[2])<<24 |
int64(b[3])<<16 | int64(b[4])<<8 | int64(b[5])
}

func TestGenerateUUIDv7_Lowercase(t *testing.T) {
Expand Down
Loading