package job import ( "context" "errors" "sync" "testing" "time" "hyapp-admin-server/internal/cache" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/repository" ) func TestArtifactJobRenewsDatabaseAndDistributedLeases(t *testing.T) { store := &heartbeatStore{} locker := &heartbeatLocker{} runner := NewRunner(store, locker, config.Config{ NodeID: "worker-heartbeat", Jobs: config.JobsConfig{LeaseTTL: 150 * time.Millisecond}, }, WithArtifactHandler("slow-export", func(ctx context.Context, _ *model.AdminJob) (string, string, error) { select { case <-ctx.Done(): return "", "", ctx.Err() case <-time.After(350 * time.Millisecond): return `{"rows":1}`, "slow.csv", nil } })) if err := runner.handleWithHeartbeat(context.Background(), &model.AdminJob{ID: 91, Type: "slow-export"}); err != nil { t.Fatalf("slow artifact handler failed: %v", err) } store.mu.Lock() renews, completes, completedBy := store.renews, store.completes, store.completedBy store.mu.Unlock() locker.mu.Lock() refreshes := locker.refreshes locker.mu.Unlock() if renews < 2 || refreshes < 2 || completes != 1 || completedBy != "worker-heartbeat" { t.Fatalf("long job lease was not kept alive: renews=%d refreshes=%d completes=%d worker=%q", renews, refreshes, completes, completedBy) } } func TestArtifactJobCancelsWhenDistributedLeaseIsLost(t *testing.T) { store := &heartbeatStore{} locker := &heartbeatLocker{loseOnRefresh: true} runner := NewRunner(store, locker, config.Config{ NodeID: "worker-lost-lock", Jobs: config.JobsConfig{LeaseTTL: 150 * time.Millisecond}, }, WithArtifactHandler("blocked-export", func(ctx context.Context, _ *model.AdminJob) (string, string, error) { <-ctx.Done() return "", "", ctx.Err() })) err := runner.handleWithHeartbeat(context.Background(), &model.AdminJob{ID: 92, Type: "blocked-export"}) if !errors.Is(err, context.Canceled) { t.Fatalf("lost distributed lock must cancel handler, err=%v", err) } store.mu.Lock() completes := store.completes store.mu.Unlock() if completes != 0 { t.Fatalf("worker that lost its lease must not complete the job: completes=%d", completes) } } type heartbeatStore struct { mu sync.Mutex renews int completes int completedBy string } func (s *heartbeatStore) LeaseNextJob(string, time.Duration, int) (*model.AdminJob, error) { return nil, errors.New("not used") } func (s *heartbeatStore) RenewJobLease(_ uint, _ string, _ time.Duration) error { s.mu.Lock() s.renews++ s.mu.Unlock() return nil } func (s *heartbeatStore) CompleteJobWithArtifact(_ uint, workerID string, _, _ string) error { s.mu.Lock() s.completes++ s.completedBy = workerID s.mu.Unlock() return nil } func (s *heartbeatStore) FailJobAttempt(uint, string, string) error { return nil } func (s *heartbeatStore) ReleaseJobLease(uint, string) error { return nil } func (s *heartbeatStore) ExportUsers(repository.ListOptions) ([]model.User, error) { return nil, nil } type heartbeatLocker struct { mu sync.Mutex refreshes int loseOnRefresh bool } func (l *heartbeatLocker) TryLock(context.Context, string, string, time.Duration) (bool, cache.UnlockFunc, error) { return true, func(context.Context) error { return nil }, nil } func (l *heartbeatLocker) RefreshLock(context.Context, string, string, time.Duration) (bool, error) { l.mu.Lock() defer l.mu.Unlock() l.refreshes++ return !l.loseOnRefresh, nil }