mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
351 lines
13 KiB
Go
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)
|
|
}
|
|
}
|