Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <cursoragent@cursor.com>
123 lines
3.1 KiB
Go
123 lines
3.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestEnqueueJobTenantsDoNotShareLock(t *testing.T) {
|
|
m := NewMemory()
|
|
ctx := context.Background()
|
|
unlock := m.lockJobEnqueueTenant("tenant-a")
|
|
var once sync.Once
|
|
release := func() { once.Do(unlock) }
|
|
t.Cleanup(release)
|
|
|
|
otherDone := make(chan error, 1)
|
|
go func() {
|
|
_, _, err := m.EnqueueJob(ctx, JobEnqueue{TenantID: "tenant-b", Kind: "test"})
|
|
otherDone <- err
|
|
}()
|
|
select {
|
|
case err := <-otherDone:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("other tenant waited on the held tenant lock")
|
|
}
|
|
|
|
sameDone := make(chan struct{})
|
|
go func() {
|
|
_, _, err := m.EnqueueJob(ctx, JobEnqueue{TenantID: "tenant-a", Kind: "test"})
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
close(sameDone)
|
|
}()
|
|
select {
|
|
case <-sameDone:
|
|
t.Fatal("same tenant did not wait on its lock")
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
release()
|
|
select {
|
|
case <-sameDone:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("same tenant did not finish after unlock")
|
|
}
|
|
}
|
|
|
|
func TestEnqueueJobIdempotencyKeyConflicts(t *testing.T) {
|
|
m := NewMemory()
|
|
ctx := context.Background()
|
|
key := "same-key"
|
|
in := JobEnqueue{TenantID: "tenant-a", Kind: "refresh", IdempotencyKey: &key, Meta: map[string]any{"module": "a"}}
|
|
first, created, err := m.EnqueueJob(ctx, in)
|
|
if err != nil || !created || first.ID == "" {
|
|
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
|
|
}
|
|
in.Meta = map[string]any{"module": "b"}
|
|
if _, _, err = m.EnqueueJob(ctx, in); !errors.Is(err, ErrJobConflict) {
|
|
t.Fatalf("same key accepted: %v", err)
|
|
}
|
|
other := JobEnqueue{TenantID: "tenant-b", Kind: "refresh", IdempotencyKey: &key, Meta: map[string]any{"module": "b"}}
|
|
if _, created, err = m.EnqueueJob(ctx, other); err != nil || !created {
|
|
t.Fatalf("other tenant created=%v err=%v", created, err)
|
|
}
|
|
}
|
|
|
|
func TestMemoryJobQueueEqualTimestampOrdering(t *testing.T) {
|
|
m := NewMemory()
|
|
ctx := context.Background()
|
|
stamp := time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC)
|
|
var ids []string
|
|
for range 32 {
|
|
j, _, err := m.EnqueueJob(ctx, JobEnqueue{TenantID: "tenant", Kind: "test"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids = append(ids, j.ID)
|
|
m.jobMu.Lock()
|
|
saved := m.jobs[j.ID]
|
|
saved.CreatedAt = stamp
|
|
m.jobs[j.ID] = saved
|
|
m.jobMu.Unlock()
|
|
}
|
|
// Repeated offset pages must use the same total order despite map iteration.
|
|
for range 5 {
|
|
cursor := ""
|
|
var listed []string
|
|
for {
|
|
page, next, more, err := m.ListJobs(ctx, "tenant", "", "", cursor, 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, j := range page {
|
|
listed = append(listed, j.ID)
|
|
}
|
|
if !more {
|
|
break
|
|
}
|
|
cursor = next
|
|
}
|
|
if len(listed) != len(ids) {
|
|
t.Fatalf("listed %d jobs", len(listed))
|
|
}
|
|
for i, id := range listed {
|
|
if id != ids[len(ids)-1-i] {
|
|
t.Fatalf("list[%d]=%s expected reverse enqueue sequence %s", i, id, ids[len(ids)-1-i])
|
|
}
|
|
}
|
|
}
|
|
for i, want := range ids {
|
|
claimed, err := m.ClaimJobs(ctx, "owner", 1, time.Minute)
|
|
if err != nil || len(claimed) != 1 || claimed[0].ID != want {
|
|
t.Fatalf("claim[%d]=%+v %v want %s", i, claimed, err, want)
|
|
}
|
|
}
|
|
}
|