Skip to content

Commit 4f21cf6

Browse files
CROSSLINK-287 Add integration tasks
1 parent f67c88a commit 4f21cf6

1 file changed

Lines changed: 298 additions & 0 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package sched_service
2+
3+
import (
4+
"context"
5+
"os"
6+
"sync"
7+
"sync/atomic"
8+
"testing"
9+
"time"
10+
11+
"github.com/google/uuid"
12+
"github.com/indexdata/crosslink/broker/app"
13+
"github.com/indexdata/crosslink/broker/common"
14+
"github.com/indexdata/crosslink/broker/events"
15+
sched_db "github.com/indexdata/crosslink/broker/scheduler/db"
16+
sched_service "github.com/indexdata/crosslink/broker/scheduler/service"
17+
test "github.com/indexdata/crosslink/broker/test/utils"
18+
"github.com/indexdata/go-utils/utils"
19+
"github.com/jackc/pgx/v5/pgtype"
20+
"github.com/stretchr/testify/assert"
21+
"github.com/testcontainers/testcontainers-go"
22+
"github.com/testcontainers/testcontainers-go/modules/postgres"
23+
"github.com/testcontainers/testcontainers-go/wait"
24+
)
25+
26+
var connString string
27+
var schedRepo sched_db.SchedRepo
28+
var appCtx = common.CreateExtCtxWithArgs(context.Background(), nil)
29+
30+
func TestMain(m *testing.M) {
31+
ctx := context.Background()
32+
33+
pgContainer, err := postgres.Run(ctx, "postgres",
34+
postgres.WithDatabase("crosslink"),
35+
postgres.WithUsername("crosslink"),
36+
postgres.WithPassword("crosslink"),
37+
testcontainers.WithWaitStrategy(
38+
wait.ForLog("database system is ready to accept connections").
39+
WithOccurrence(2).WithStartupTimeout(30*time.Second)),
40+
)
41+
test.Expect(err, "failed to start db container")
42+
43+
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
44+
test.Expect(err, "failed to get conn string")
45+
46+
connString = connStr
47+
app.ConnectionString = connStr
48+
app.MigrationsFolder = "file://../../../migrations"
49+
app.HTTP_PORT = utils.Must(test.GetFreePort())
50+
app.DB_PROVISION = true
51+
52+
test.Expect(app.RunDbUp(), "failed to run db migrations")
53+
54+
pool, err := app.InitDbPool()
55+
test.Expect(err, "failed to init db pool")
56+
57+
schedRepo = sched_db.CreateSchedRepo(pool)
58+
59+
code := m.Run()
60+
61+
test.Expect(pgContainer.Terminate(ctx), "failed to stop db container")
62+
os.Exit(code)
63+
}
64+
65+
// ---------------------------------------------------------------------------
66+
// Helpers
67+
// ---------------------------------------------------------------------------
68+
69+
// countingEventBus records dispatched tasks and is safe for concurrent use.
70+
type countingEventBus struct {
71+
events.EventBus
72+
mu sync.Mutex
73+
claims []string
74+
}
75+
76+
func (b *countingEventBus) CreateTask(_ string, _ events.EventName, _ events.EventData, _ events.EventDomain, _ *string, _ events.SignalTarget) (string, error) {
77+
b.mu.Lock()
78+
defer b.mu.Unlock()
79+
b.claims = append(b.claims, uuid.NewString())
80+
return uuid.NewString(), nil
81+
}
82+
83+
func (b *countingEventBus) totalClaims() int {
84+
b.mu.Lock()
85+
defer b.mu.Unlock()
86+
return len(b.claims)
87+
}
88+
89+
func overdueTask() sched_db.SaveScheduledTaskParams {
90+
return sched_db.SaveScheduledTaskParams{
91+
ID: uuid.NewString(),
92+
EventName: events.EventNameSendNotification,
93+
CronExpr: "",
94+
RunAt: pgtype.Timestamptz{Time: time.Now().Add(-1 * time.Second), Valid: true},
95+
Status: sched_db.ScheduledTaskStatusPending,
96+
CreatedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
97+
}
98+
}
99+
100+
func startScheduler(t *testing.T, ctx context.Context, bus events.EventBus) {
101+
t.Helper()
102+
pool, err := app.InitDbPool()
103+
assert.NoError(t, err)
104+
repo := sched_db.CreateSchedRepo(pool)
105+
svc := sched_service.NewSchedulerService(repo, bus, connString)
106+
extCtx := common.CreateExtCtxWithArgs(ctx, nil)
107+
assert.NoError(t, svc.Listen(extCtx))
108+
go svc.Run(extCtx)
109+
}
110+
111+
// ---------------------------------------------------------------------------
112+
// Multi-instance: no double processing
113+
// ---------------------------------------------------------------------------
114+
115+
// TestMultipleInstances_TaskClaimedExactlyOnce verifies that two scheduler
116+
// instances running concurrently dispatch a single overdue task exactly once.
117+
// FOR UPDATE SKIP LOCKED prevents double claiming.
118+
func TestMultipleInstances_TaskClaimedExactlyOnce(t *testing.T) {
119+
bus := &countingEventBus{}
120+
ctx, cancel := context.WithCancel(context.Background())
121+
defer cancel()
122+
123+
params := overdueTask()
124+
_, err := schedRepo.SaveScheduledTask(appCtx, params)
125+
assert.NoError(t, err)
126+
127+
startScheduler(t, ctx, bus)
128+
startScheduler(t, ctx, bus)
129+
130+
assert.True(t, test.WaitForPredicateToBeTrue(func() bool {
131+
return bus.totalClaims() >= 1
132+
}), "task was never claimed")
133+
134+
time.Sleep(150 * time.Millisecond) // extra time to catch any duplicate
135+
136+
assert.Equal(t, 1, bus.totalClaims(), "task must be claimed exactly once")
137+
}
138+
139+
// TestMultipleInstances_EachTaskClaimedOnce verifies N tasks across M instances
140+
// are each dispatched exactly once — no duplication, no starvation.
141+
func TestMultipleInstances_EachTaskClaimedOnce(t *testing.T) {
142+
const taskCount = 5
143+
const instanceCount = 3
144+
145+
bus := &countingEventBus{}
146+
ctx, cancel := context.WithCancel(context.Background())
147+
defer cancel()
148+
149+
for i := 0; i < taskCount; i++ {
150+
_, err := schedRepo.SaveScheduledTask(appCtx, overdueTask())
151+
assert.NoError(t, err)
152+
}
153+
154+
for i := 0; i < instanceCount; i++ {
155+
startScheduler(t, ctx, bus)
156+
}
157+
158+
assert.True(t, test.WaitForPredicateToBeTrue(func() bool {
159+
return bus.totalClaims() >= taskCount
160+
}), "not all tasks were claimed")
161+
162+
time.Sleep(150 * time.Millisecond)
163+
164+
assert.Equal(t, taskCount, bus.totalClaims(), "each task must be dispatched exactly once")
165+
}
166+
167+
// TestMultipleInstances_HighConcurrency runs 10 tasks across 5 instances and
168+
// asserts each task is claimed exactly once under higher parallelism.
169+
func TestMultipleInstances_HighConcurrency(t *testing.T) {
170+
const taskCount = 10
171+
const instanceCount = 5
172+
173+
var total atomic.Int64
174+
bus := &countingEventBus{}
175+
ctx, cancel := context.WithCancel(context.Background())
176+
defer cancel()
177+
178+
for i := 0; i < taskCount; i++ {
179+
_, err := schedRepo.SaveScheduledTask(appCtx, overdueTask())
180+
assert.NoError(t, err)
181+
}
182+
183+
for i := 0; i < instanceCount; i++ {
184+
startScheduler(t, ctx, bus)
185+
}
186+
187+
assert.True(t, test.WaitForPredicateToBeTrue(func() bool {
188+
return bus.totalClaims() >= taskCount
189+
}), "not all tasks claimed")
190+
191+
time.Sleep(200 * time.Millisecond)
192+
total.Store(int64(bus.totalClaims()))
193+
194+
assert.Equal(t, int64(taskCount), total.Load(),
195+
"all tasks must be dispatched exactly once across all instances")
196+
}
197+
198+
// ---------------------------------------------------------------------------
199+
// LISTEN/NOTIFY wake-up
200+
// ---------------------------------------------------------------------------
201+
202+
// TestListen_NotifyWakesScheduler verifies that inserting a new overdue task
203+
// (which triggers NOTIFY internally) wakes the sleeping scheduler well within
204+
// the fallback poll window.
205+
func TestListen_NotifyWakesScheduler(t *testing.T) {
206+
bus := &countingEventBus{}
207+
ctx, cancel := context.WithCancel(context.Background())
208+
defer cancel()
209+
210+
startScheduler(t, ctx, bus)
211+
212+
// Give the scheduler time to enter its wait state.
213+
time.Sleep(100 * time.Millisecond)
214+
215+
// SaveScheduledTask sends NOTIFY crosslink_sched_channel internally.
216+
_, err := schedRepo.SaveScheduledTask(appCtx, overdueTask())
217+
assert.NoError(t, err)
218+
219+
// Should wake within ~200ms due to NOTIFY, not wait for the 5-min fallback.
220+
assert.True(t, test.WaitForPredicateToBeTrue(func() bool {
221+
return bus.totalClaims() >= 1
222+
}), "scheduler was not woken by NOTIFY")
223+
}
224+
225+
// ---------------------------------------------------------------------------
226+
// Reconnect after connection loss
227+
// ---------------------------------------------------------------------------
228+
229+
// TestListen_ReconnectsAfterConnectionLoss terminates the scheduler's LISTEN
230+
// connection via pg_terminate_backend and verifies the scheduler reconnects
231+
// and continues processing new tasks afterwards.
232+
func TestListen_ReconnectsAfterConnectionLoss(t *testing.T) {
233+
bus := &countingEventBus{}
234+
ctx, cancel := context.WithCancel(context.Background())
235+
defer cancel()
236+
237+
startScheduler(t, ctx, bus)
238+
239+
// Let the scheduler reach its idle wait state.
240+
time.Sleep(150 * time.Millisecond)
241+
242+
// Kill all LISTEN connections to simulate a network interruption.
243+
adminPool, err := app.InitDbPool()
244+
assert.NoError(t, err)
245+
killCtx := common.CreateExtCtxWithArgs(context.Background(), nil)
246+
_, err = adminPool.Exec(killCtx,
247+
`SELECT pg_terminate_backend(pid)
248+
FROM pg_stat_activity
249+
WHERE query LIKE $1
250+
AND pid <> pg_backend_pid()`,
251+
"LISTEN%")
252+
assert.NoError(t, err)
253+
254+
// Allow time for reconnect (exponential backoff starts at 1 s).
255+
time.Sleep(2 * time.Second)
256+
257+
// After reconnect the scheduler must still pick up newly inserted tasks.
258+
_, err = schedRepo.SaveScheduledTask(appCtx, overdueTask())
259+
assert.NoError(t, err)
260+
261+
assert.True(t, test.WaitForPredicateToBeTrue(func() bool {
262+
return bus.totalClaims() >= 1
263+
}), "scheduler did not recover after connection loss")
264+
}
265+
266+
// ---------------------------------------------------------------------------
267+
// Context cancellation
268+
// ---------------------------------------------------------------------------
269+
270+
// TestScheduler_StopsOnContextCancel verifies that cancelling the context
271+
// causes the Run() loop to exit cleanly within a reasonable time.
272+
func TestScheduler_StopsOnContextCancel(t *testing.T) {
273+
bus := &countingEventBus{}
274+
ctx, cancel := context.WithCancel(context.Background())
275+
276+
pool, err := app.InitDbPool()
277+
assert.NoError(t, err)
278+
repo := sched_db.CreateSchedRepo(pool)
279+
svc := sched_service.NewSchedulerService(repo, bus, connString)
280+
extCtx := common.CreateExtCtxWithArgs(ctx, nil)
281+
assert.NoError(t, svc.Listen(extCtx))
282+
283+
stopped := make(chan struct{})
284+
go func() {
285+
svc.Run(extCtx)
286+
close(stopped)
287+
}()
288+
289+
time.Sleep(50 * time.Millisecond)
290+
cancel()
291+
292+
select {
293+
case <-stopped:
294+
// scheduler loop exited cleanly
295+
case <-time.After(3 * time.Second):
296+
t.Fatal("scheduler did not stop after context cancellation")
297+
}
298+
}

0 commit comments

Comments
 (0)