Files
OpenFlare/backend/openflare/plugins/server/domain/pages/source_runtime_test.go
T

351 lines
13 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"errors"
"strings"
"sync"
"testing"
"time"
"Wavelet/openflare/plugins/server/kernel/repository"
"Wavelet/openflare/plugins/server/kernel/model"
)
func TestSourceLeaseHeartbeatRenewsAndCancelsOnOwnershipLoss(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "lease-heartbeat")
source, _ := mustConfigureRemoteSource(
t,
ctx,
project.ID,
"https://example.com/site.zip",
false,
)
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
if err != nil || outcome != sourceLeaseAcquired || snapshot == nil {
t.Fatalf("acquireSourceLease(heartbeat) = (%+v, %q, %v), want acquired", snapshot, outcome, err)
}
workCtx, heartbeat, err := startSourceLeaseHeartbeat(ctx, snapshot, 500*time.Millisecond, 20*time.Millisecond)
if err != nil {
t.Fatalf("startSourceLeaseHeartbeat() error = %v, want nil", err)
}
t.Cleanup(func() { _ = heartbeat.stop() })
var initial model.PagesProjectSourceRuntime
if err := repository.DB(ctx).Where("source_id = ?", source.ID).First(&initial).Error; err != nil {
t.Fatalf("load initial heartbeat runtime error = %v, want nil", err)
}
if initial.LeaseExpiresAt == nil {
t.Fatal("initial heartbeat expiry = nil, want non-nil")
}
deadline := time.Now().Add(2 * time.Second)
for {
var renewedRuntime model.PagesProjectSourceRuntime
if err := repository.DB(ctx).Where("source_id = ?", source.ID).First(&renewedRuntime).Error; err != nil {
t.Fatalf("load renewed heartbeat runtime error = %v, want nil", err)
}
if renewedRuntime.LeaseExpiresAt != nil && renewedRuntime.LeaseExpiresAt.After(*initial.LeaseExpiresAt) {
break
}
if time.Now().After(deadline) {
t.Fatal("heartbeat did not extend lease before deadline")
}
time.Sleep(10 * time.Millisecond)
}
if err := repository.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", source.ID).
Update("lease_token", "replacement-owner").Error; err != nil {
t.Fatalf("replace heartbeat lease owner error = %v, want nil", err)
}
select {
case <-workCtx.Done():
case <-time.After(2 * time.Second):
t.Fatal("heartbeat work context was not canceled after ownership loss")
}
if err := heartbeat.stop(); !errors.Is(err, errSourceLeaseHeartbeatLost) {
t.Fatalf("heartbeat.stop() error = %v, want %v", err, errSourceLeaseHeartbeatLost)
}
if err := heartbeat.stop(); !errors.Is(err, errSourceLeaseHeartbeatLost) {
t.Fatalf("heartbeat.stop() second error = %v, want stable %v", err, errSourceLeaseHeartbeatLost)
}
}
func TestAcquireSourceLeaseConcurrentOnlyOneOwner(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "lease-concurrent")
source, _ := mustConfigureRemoteSource(
t,
ctx,
project.ID,
"https://example.com/site.zip",
false,
)
type leaseResult struct {
snapshot *sourceExecutionSnapshot
outcome sourceLeaseOutcome
err error
}
results := make(chan leaseResult, 2)
var workers sync.WaitGroup
workers.Add(2)
for range 2 {
go func() {
defer workers.Done()
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
results <- leaseResult{snapshot: snapshot, outcome: outcome, err: err}
}()
}
workers.Wait()
close(results)
acquired := 0
busy := 0
for result := range results {
if result.err != nil {
t.Errorf("acquireSourceLease(concurrent) error = %v, want nil", result.err)
continue
}
switch result.outcome {
case sourceLeaseAcquired:
acquired++
if result.snapshot == nil || result.snapshot.LeaseToken == "" {
t.Errorf("acquireSourceLease(concurrent acquired) snapshot = %+v, want token-bearing snapshot", result.snapshot)
}
case sourceLeaseBusy:
busy++
if result.snapshot != nil {
t.Errorf("acquireSourceLease(concurrent busy) snapshot = %+v, want nil", result.snapshot)
}
default:
t.Errorf("acquireSourceLease(concurrent) outcome = %q, want acquired or busy", result.outcome)
}
}
if acquired != 1 || busy != 1 {
t.Errorf("concurrent lease outcomes = acquired:%d busy:%d, want 1 and 1", acquired, busy)
}
}
func TestAcquireSourceLeaseMutualExclusionExpiryAndTerminalOwnership(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "lease-cas")
source, _ := mustConfigureRemoteSource(
t,
ctx,
project.ID,
"https://example.com/site.zip",
false,
)
first, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
if err != nil {
t.Fatalf("acquireSourceLease(first) error = %v, want nil", err)
}
if got, want := outcome, sourceLeaseAcquired; got != want {
t.Fatalf("acquireSourceLease(first) outcome = %q, want %q", got, want)
}
if first == nil || first.LeaseToken == "" {
t.Fatalf("acquireSourceLease(first) snapshot = %+v, want token-bearing snapshot", first)
}
second, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
if err != nil {
t.Fatalf("acquireSourceLease(duplicate) error = %v, want nil", err)
}
if got, want := outcome, sourceLeaseBusy; got != want {
t.Errorf("acquireSourceLease(duplicate) outcome = %q, want %q", got, want)
}
if second != nil {
t.Errorf("acquireSourceLease(duplicate) snapshot = %+v, want nil", second)
}
past := time.Now().Add(-time.Second)
if err := repository.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", source.ID).
Update("lease_expires_at", &past).Error; err != nil {
t.Fatalf("expire first lease error = %v, want nil", err)
}
takeover, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
if err != nil {
t.Fatalf("acquireSourceLease(takeover) error = %v, want nil", err)
}
if got, want := outcome, sourceLeaseAcquired; got != want {
t.Fatalf("acquireSourceLease(takeover) outcome = %q, want %q", got, want)
}
if takeover == nil {
t.Fatal("acquireSourceLease(takeover) snapshot = nil, want non-nil")
}
if takeover.LeaseToken == "" || takeover.LeaseToken == first.LeaseToken {
t.Fatalf("takeover LeaseToken = %q, want non-empty token distinct from %q", takeover.LeaseToken, first.LeaseToken)
}
renewed, err := renewSourceLease(ctx, first, pagesSourceSyncLeaseDuration)
if err != nil {
t.Fatalf("renewSourceLease(expired owner) error = %v, want nil", err)
}
if renewed {
t.Error("renewSourceLease(expired owner) = true, want false")
}
if err := failSourceLease(ctx, first, "stale worker must not win"); err != nil {
t.Fatalf("failSourceLease(expired owner) error = %v, want nil", err)
}
var runtime model.PagesProjectSourceRuntime
if err := repository.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
t.Fatalf("load runtime after takeover error = %v, want nil", err)
}
if got, want := runtime.LeaseToken, takeover.LeaseToken; got != want {
t.Errorf("runtime LeaseToken after stale terminal write = %q, want %q", got, want)
}
if got, want := runtime.SyncStatus, pagesSourceStatusSyncing; got != want {
t.Errorf("runtime SyncStatus after stale terminal write = %q, want %q", got, want)
}
renewed, err = renewSourceLease(ctx, takeover, pagesSourceSyncLeaseDuration)
if err != nil {
t.Fatalf("renewSourceLease(current owner) error = %v, want nil", err)
}
if !renewed {
t.Error("renewSourceLease(current owner) = false, want true")
}
if err := failSourceLease(ctx, takeover, errPagesSourceSyncFailed); err != nil {
t.Fatalf("failSourceLease(current owner) error = %v, want nil", err)
}
var failedRuntime model.PagesProjectSourceRuntime
if err := repository.DB(ctx).Where("source_id = ?", source.ID).First(&failedRuntime).Error; err != nil {
t.Fatalf("load failed runtime error = %v, want nil", err)
}
if got, want := failedRuntime.SyncStatus, pagesSourceStatusFailed; got != want {
t.Errorf("failed runtime SyncStatus = %q, want %q", got, want)
}
if failedRuntime.LeaseToken != "" || failedRuntime.LeaseExpiresAt != nil {
t.Errorf("failed runtime lease = (%q, %v), want cleared", failedRuntime.LeaseToken, failedRuntime.LeaseExpiresAt)
}
}
func TestSourceConfigAndProjectContentChangesFenceLease(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "lease-fence")
source, _ := mustConfigureRemoteSource(
t,
ctx,
project.ID,
"https://example.com/site.zip?token=first",
false,
)
configSnapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
if err != nil || outcome != sourceLeaseAcquired {
t.Fatalf("acquireSourceLease(config fence) = (%+v, %q, %v), want acquired", configSnapshot, outcome, err)
}
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURL: "https://example.com/site.zip?token=second",
AllowInsecure: false,
}); err != nil {
t.Fatalf("UpdateSource(config fence) error = %v, want nil", err)
}
renewed, err := renewSourceLease(ctx, configSnapshot, pagesSourceSyncLeaseDuration)
if err != nil {
t.Fatalf("renewSourceLease(after source update) error = %v, want nil", err)
}
if renewed {
t.Error("renewSourceLease(after source update) = true, want false")
}
var updatedSource model.PagesProjectSource
if err := repository.DB(ctx).Where("id = ?", source.ID).First(&updatedSource).Error; err != nil {
t.Fatalf("load updated source error = %v, want nil", err)
}
if got, want := updatedSource.ConfigVersion, source.ConfigVersion+1; got != want {
t.Errorf("updated source ConfigVersion = %d, want %d", got, want)
}
if snapshot, staleOutcome, staleErr := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync); staleErr != nil || staleOutcome != sourceLeaseStale || snapshot != nil {
t.Errorf("acquireSourceLease(old config) = (%+v, %q, %v), want (nil, %q, nil)", snapshot, staleOutcome, staleErr, sourceLeaseStale)
}
contentSnapshot, outcome, err := acquireSourceLease(ctx, source.ID, updatedSource.ConfigVersion, sourceActionSync)
if err != nil || outcome != sourceLeaseAcquired {
t.Fatalf("acquireSourceLease(content fence) = (%+v, %q, %v), want acquired", contentSnapshot, outcome, err)
}
if _, err := UpdateProject(ctx, project.ID, Input{
Name: project.Name,
Slug: project.Slug,
Enabled: true,
RootDir: "dist",
EntryFile: "index.html",
}); err != nil {
t.Fatalf("UpdateProject(content fence) error = %v, want nil", err)
}
renewed, err = renewSourceLease(ctx, contentSnapshot, pagesSourceSyncLeaseDuration)
if err != nil {
t.Fatalf("renewSourceLease(after content update) error = %v, want nil", err)
}
if renewed {
t.Error("renewSourceLease(after content update) = true, want false")
}
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
if err != nil {
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", project.ID, err)
}
if got, want := storedProject.ContentConfigVersion, project.ContentConfigVersion+1; got != want {
t.Errorf("ContentConfigVersion after RootDir update = %d, want %d", got, want)
}
var runtime model.PagesProjectSourceRuntime
if err := repository.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
t.Fatalf("load fenced runtime error = %v, want nil", err)
}
if runtime.LeaseToken != "" || runtime.LeaseExpiresAt != nil {
t.Errorf("content-fenced runtime lease = (%q, %v), want cleared", runtime.LeaseToken, runtime.LeaseExpiresAt)
}
}
func TestSourceRuntimeUsesOnlySixDocumentedStates(t *testing.T) {
states := []string{
pagesSourceStatusIdle,
pagesSourceStatusChecking,
pagesSourceStatusUpdateAvailable,
pagesSourceStatusSyncing,
pagesSourceStatusFailed,
pagesSourceStatusAttention,
}
seen := make(map[string]struct{}, len(states))
for _, state := range states {
if strings.TrimSpace(state) == "" {
t.Errorf("documented source state = %q, want non-empty", state)
}
if _, exists := seen[state]; exists {
t.Errorf("documented source state %q is duplicated", state)
}
seen[state] = struct{}{}
}
if got, want := len(seen), 6; got != want {
t.Errorf("unique source states = %d, want %d", got, want)
}
updateRuntime := &model.PagesProjectSourceRuntime{
LastSeenRevision: strings.Repeat("a", 64),
LastAppliedRevision: strings.Repeat("b", 64),
LastSeenDetail: `{"release_id":"new"}`,
LastAppliedDetail: `{"release_id":"old"}`,
}
if got, want := normalizedSourceRuntimeStatus(updateRuntime), pagesSourceStatusUpdateAvailable; got != want {
t.Errorf("normalizedSourceRuntimeStatus(update) = %q, want %q", got, want)
}
attentionRuntime := &model.PagesProjectSourceRuntime{
LastSeenRevision: strings.Repeat("a", 64),
LastAppliedRevision: strings.Repeat("b", 64),
LastSeenDetail: `{"release_id":"same"}`,
LastAppliedDetail: `{"release_id":"same"}`,
}
if got, want := normalizedSourceRuntimeStatus(attentionRuntime), pagesSourceStatusAttention; got != want {
t.Errorf("normalizedSourceRuntimeStatus(attention) = %q, want %q", got, want)
}
if got, want := normalizedSourceRuntimeStatus(&model.PagesProjectSourceRuntime{}), pagesSourceStatusIdle; got != want {
t.Errorf("normalizedSourceRuntimeStatus(idle) = %q, want %q", got, want)
}
}