Files
OpenFlare/internal/apps/openflare/pages/source_sync.go
T
ryan 55db1c01ec 后台 goroutine panic 防护:新增 pkg/util.Go 共享助手(recover+调用点日志),全仓 22 个裸 go func() 站点统一收口
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":112,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 11:06:23 +08:00

735 lines
20 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"encoding/json"
"errors"
"fmt"
"path"
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/util"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
pagesSourceTriggerManualSync = "manual_sync"
pagesSourceTriggerScheduledAutoUpdate = "scheduled_auto_update"
pagesSourceCreatedBySystem = "system:pages-source-sync"
pagesSourceHeartbeatInterval = pagesSourceSyncLeaseDuration / 3
pagesSourceCleanupTimeout = 15 * time.Second
)
var (
errSourceFinalFence = errors.New("pages source final fence rejected")
errSourceLeaseHeartbeatLost = errors.New("pages source lease heartbeat lost")
sourceCommitNow = time.Now
)
type sourceSyncOutcome struct {
Deployment *DeploymentView
Reused bool
Stale bool
}
type preparedRemoteSource struct {
Candidate *SourceCandidate
Manifest *deploymentManifest
Detail sourceDetail
DetailJSON string
}
type sourceIngestState struct {
Result upload.IngestResult
HasIngest bool
Referenced bool
}
type sourceCommitState struct {
Project *model.PagesProject
Source *model.PagesProjectSource
Runtime *model.PagesProjectSourceRuntime
Now time.Time
}
type sourceLeaseHeartbeat struct {
cancel context.CancelFunc
done <-chan error
stopOnce sync.Once
stopErr error
}
func startSourceLeaseHeartbeat(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
leaseDuration time.Duration,
interval time.Duration,
) (context.Context, *sourceLeaseHeartbeat, error) {
if snapshot == nil || leaseDuration <= 0 || interval <= 0 || interval >= leaseDuration {
return nil, nil, errors.New(errPagesSourceLeaseLost)
}
renewed, err := renewSourceLease(ctx, snapshot, leaseDuration)
if err != nil {
return nil, nil, err
}
if !renewed {
return nil, nil, errSourceLeaseHeartbeatLost
}
workCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
heartbeat := &sourceLeaseHeartbeat{cancel: cancel, done: done}
util.Go(func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-workCtx.Done():
done <- nil
return
case <-ticker.C:
renewed, renewErr := renewSourceLease(workCtx, snapshot, leaseDuration)
if renewErr != nil {
if workCtx.Err() != nil {
done <- nil
return
}
done <- renewErr
cancel()
return
}
if !renewed {
done <- errSourceLeaseHeartbeatLost
cancel()
return
}
}
}
})
return workCtx, heartbeat, nil
}
func (heartbeat *sourceLeaseHeartbeat) stop() error {
if heartbeat == nil {
return nil
}
heartbeat.stopOnce.Do(func() {
heartbeat.cancel()
heartbeat.stopErr = <-heartbeat.done
})
return heartbeat.stopErr
}
func sourceHeartbeatOutcome(err error) (*sourceSyncOutcome, error) {
if errors.Is(err, errSourceLeaseHeartbeatLost) {
return &sourceSyncOutcome{Stale: true}, nil
}
return nil, err
}
func recordSourceLeaseFailure(ctx context.Context, snapshot *sourceExecutionSnapshot) {
cleanupCtx, cancel := sourceCleanupContext(ctx)
defer cancel()
if err := failSourceLease(cleanupCtx, snapshot, errPagesSourceSyncFailed); err != nil {
var sourceID uint
if snapshot != nil {
sourceID = snapshot.SourceID
}
logger.WarnF(cleanupCtx, "[PagesSource] record failed runtime state failed: source_id=%d error=%v", sourceID, err)
}
}
func sourceCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), pagesSourceCleanupTimeout)
}
func syncRemoteSourceWithTrigger(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
actor string,
triggerType string,
) (outcome *sourceSyncOutcome, resultErr error) {
if snapshot == nil || snapshot.SourceType != PagesSourceTypeRemoteURL {
return nil, errors.New(errPagesSourceTypeUnsupported)
}
actor = strings.TrimSpace(actor)
if actor == "" || !validSourceDeploymentTrigger(triggerType) {
return nil, errors.New(errPagesSourceActionInvalid)
}
defer func() {
if resultErr != nil {
recordSourceLeaseFailure(ctx, snapshot)
}
}()
workCtx, heartbeat, err := startSourceLeaseHeartbeat(
ctx,
snapshot,
pagesSourceSyncLeaseDuration,
pagesSourceHeartbeatInterval,
)
if err != nil {
return sourceHeartbeatOutcome(err)
}
defer func() {
_ = heartbeat.stop()
}()
limits := resolvePagesLimits(workCtx)
prepared, err := prepareRemoteSource(workCtx, snapshot, limits)
if err != nil {
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
return sourceHeartbeatOutcome(heartbeatErr)
}
return nil, err
}
defer func() {
if cleanupErr := prepared.Candidate.Cleanup(); cleanupErr != nil {
logger.WarnF(ctx, "[PagesSource] cleanup temporary package failed: source_id=%d error=%v", snapshot.SourceID, cleanupErr)
}
}()
ingestState, err := resolveSourceIngest(workCtx, snapshot, prepared)
if err != nil {
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
return sourceHeartbeatOutcome(heartbeatErr)
}
return nil, err
}
defer func() {
compensateSourceIngest(ctx, snapshot, ingestState)
}()
if heartbeatErr := heartbeat.stop(); heartbeatErr != nil {
return sourceHeartbeatOutcome(heartbeatErr)
}
renewed, err := renewSourceLease(ctx, snapshot, pagesSourceSyncLeaseDuration)
if err != nil {
return nil, err
}
if !renewed {
return &sourceSyncOutcome{Stale: true}, nil
}
task.AppendLog(ctx, "[activate] 正在原子切换生产部署")
deployment, reused, referenced, err := commitSourceDeploymentWithTrigger(
ctx,
snapshot,
prepared.Candidate.Checksum,
prepared.Candidate.Checksum,
prepared.Detail,
prepared.DetailJSON,
actor,
triggerType,
prepared.Manifest,
ingestState.Result,
ingestState.HasIngest,
nil,
)
ingestState.Referenced = referenced
if errors.Is(err, errSourceFinalFence) {
return &sourceSyncOutcome{Stale: true}, nil
}
if err != nil {
return nil, err
}
ingestState.Referenced = ingestState.HasIngest && deployment.UploadID == ingestState.Result.Upload.ID
if pruneErr := pruneProjectDeploymentHistory(ctx, snapshot.ProjectID, limits.HistoryCount, 0); pruneErr != nil {
logger.ErrorF(ctx,
"[PagesSource] strict prune failed: project_id=%d source_id=%d keep=%d error=%v",
snapshot.ProjectID,
snapshot.SourceID,
limits.HistoryCount,
pruneErr,
)
}
view := buildDeploymentView(deployment)
return &sourceSyncOutcome{Deployment: &view, Reused: reused}, nil
}
func prepareRemoteSource(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
limits pagesLimits,
) (*preparedRemoteSource, error) {
task.AppendLog(ctx, "[download] 正在获取远程部署包")
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
URL: snapshot.RemoteURL,
AllowInsecure: snapshot.AllowInsecure,
MaxPackageBytes: limits.PackageBytes,
})
if err != nil {
return nil, err
}
if candidate == nil || candidate.TempPath == "" || candidate.Checksum == "" || candidate.Format == "" {
return nil, errors.New(errPagesSourceSyncFailed)
}
rootDir, err := validateAndNormalizePagesRootDir(snapshot.RootDir)
if err != nil {
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
return nil, err
}
entryFile, err := validateAndNormalizePagesEntryFile(snapshot.EntryFile)
if err != nil {
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
return nil, err
}
task.AppendLog(ctx, "[verify] 正在校验归档结构与入口文件")
manifest, err := inspectPagesPackage(candidate.TempPath, candidate.Format, rootDir, entryFile, limits)
if err != nil {
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
return nil, err
}
detail := sourceDetail{Provider: PagesSourceTypeRemoteURL, DisplayName: safeRemoteSourceLabel(candidate.SafeLabel)}
detailJSON, err := json.Marshal(detail)
if err != nil {
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
return nil, err
}
return &preparedRemoteSource{
Candidate: candidate,
Manifest: manifest,
Detail: detail,
DetailJSON: string(detailJSON),
}, nil
}
func cleanupFailedRemoteCandidate(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
candidate *SourceCandidate,
) {
if err := candidate.Cleanup(); err != nil {
logger.WarnF(ctx, "[PagesSource] cleanup failed preparation package: source_id=%d error=%v", snapshot.SourceID, err)
}
}
func resolveSourceIngest(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
prepared *preparedRemoteSource,
) (*sourceIngestState, error) {
_, err := findSourceDeployment(
ctx,
snapshot.ProjectID,
snapshot.SourceIdentity,
prepared.Candidate.Checksum,
)
if err == nil {
return &sourceIngestState{}, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
task.AppendLog(ctx, "[ingest] 正在保存受管部署包")
result, err := ingestPagesDeploymentPackageWithSource(
ctx,
prepared.Candidate.TempPath,
prepared.Candidate.Checksum,
snapshot.ProjectID,
snapshot.SourceID,
sourceDetailLabel(prepared.Detail),
prepared.Candidate.Format,
)
if err != nil {
return nil, err
}
return &sourceIngestState{Result: result, HasIngest: true}, nil
}
func compensateSourceIngest(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
state *sourceIngestState,
) {
if state == nil || !state.HasIngest || !state.Result.Created || state.Referenced {
return
}
cleanupCtx, cancel := sourceCleanupContext(ctx)
defer cancel()
task.AppendLog(cleanupCtx, "[cleanup] 正在补偿未引用的部署包记录")
if err := removePagesUploadIfUnreferenced(cleanupCtx, snapshot.ProjectID, state.Result.Upload.ID); err != nil {
logger.ErrorF(cleanupCtx,
"[PagesSource] compensate upload failed: project_id=%d source_id=%d upload_id=%d error=%v",
snapshot.ProjectID, snapshot.SourceID, state.Result.Upload.ID, err,
)
}
}
func findSourceDeployment(
ctx context.Context,
projectID uint,
sourceIdentity string,
revision string,
) (*model.PagesDeployment, error) {
return repository.GetPagesDeploymentBySourceRevision(ctx, projectID, sourceIdentity, revision)
}
func commitSourceDeploymentWithTrigger(
ctx context.Context,
snapshot *sourceExecutionSnapshot,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
triggerType string,
manifest *deploymentManifest,
ingestResult upload.IngestResult,
hasIngest bool,
nextCheckNotBefore *time.Time,
) (*model.PagesDeployment, bool, bool, error) {
if snapshot == nil || manifest == nil {
return nil, false, false, errors.New(errPagesSourceSyncFailed)
}
if !validSourceDeploymentTrigger(triggerType) {
return nil, false, false, errors.New(errPagesSourceActionInvalid)
}
var committed model.PagesDeployment
reused := false
ingestReferenced := false
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
state, err := lockSourceCommitState(tx, snapshot)
if err != nil {
return err
}
target, targetReused, err := resolveSourceDeploymentTx(
tx, state, revision, packageChecksum, detail, detailJSON, actor, triggerType,
manifest, ingestResult, hasIngest,
)
if err != nil {
return err
}
if err := lockSourceDeploymentUploadsTx(tx, target, ingestResult, hasIngest); err != nil {
return err
}
if err := ensureDeploymentEntry(tx, target.ID, state.Project.RootDir, state.Project.EntryFile); err != nil {
return err
}
if err := refreshSourceCommitLease(state, snapshot); err != nil {
return err
}
if err := activateSourceDeploymentTx(tx, state, target, revision, detailJSON, nextCheckNotBefore); err != nil {
return err
}
committed = *target
reused = targetReused
ingestReferenced = hasIngest && target.UploadID == ingestResult.Upload.ID
return nil
})
if err != nil {
return nil, false, false, err
}
return &committed, reused, ingestReferenced, nil
}
func lockSourceCommitState(tx *gorm.DB, snapshot *sourceExecutionSnapshot) (*sourceCommitState, error) {
state := &sourceCommitState{}
project, err := repository.LockPagesProjectByIDTx(tx, snapshot.ProjectID)
if err != nil {
return nil, sourceFenceRecordError(err)
}
if project.ContentConfigVersion != snapshot.ContentConfigVersion {
return nil, errSourceFinalFence
}
source, err := repository.LockPagesProjectSourceByIDAndProjectIDTx(tx, snapshot.SourceID, snapshot.ProjectID)
if err != nil {
return nil, sourceFenceRecordError(err)
}
if source.ConfigVersion != snapshot.SourceConfigVersion ||
source.SourceIdentity != snapshot.SourceIdentity ||
source.SourceType != snapshot.SourceType {
return nil, errSourceFinalFence
}
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, source.ID)
if err != nil {
return nil, sourceFenceRecordError(err)
}
state.Project = project
state.Source = source
state.Runtime = runtime
if err := refreshSourceCommitLease(state, snapshot); err != nil {
return nil, err
}
return state, nil
}
func refreshSourceCommitLease(state *sourceCommitState, snapshot *sourceExecutionSnapshot) error {
if state == nil || state.Runtime == nil || snapshot == nil {
return errSourceFinalFence
}
now := sourceCommitNow()
if state.Runtime.LeaseToken != snapshot.LeaseToken || state.Runtime.LeaseExpiresAt == nil ||
!state.Runtime.LeaseExpiresAt.After(now) {
return errSourceFinalFence
}
state.Now = now
return nil
}
func sourceFenceRecordError(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errSourceFinalFence
}
return err
}
func resolveSourceDeploymentTx(
tx *gorm.DB,
state *sourceCommitState,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
triggerType string,
manifest *deploymentManifest,
ingestResult upload.IngestResult,
hasIngest bool,
) (*model.PagesDeployment, bool, error) {
var target model.PagesDeployment
err := tx.Where(
"project_id = ? AND source_identity = ? AND source_revision = ?",
state.Project.ID,
state.Source.SourceIdentity,
revision,
).First(&target).Error
if err == nil {
return &target, true, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
if !hasIngest {
return nil, false, errSourceFinalFence
}
return createSourceDeploymentTx(
tx, state, revision, packageChecksum, detail, detailJSON, actor, triggerType, manifest, ingestResult,
)
}
func createSourceDeploymentTx(
tx *gorm.DB,
state *sourceCommitState,
revision string,
packageChecksum string,
detail sourceDetail,
detailJSON string,
actor string,
triggerType string,
manifest *deploymentManifest,
ingestResult upload.IngestResult,
) (*model.PagesDeployment, bool, error) {
var maxNumber int
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", state.Project.ID).
Select("COALESCE(MAX(deployment_number), 0)").
Scan(&maxNumber).Error; err != nil {
return nil, false, err
}
identity := state.Source.SourceIdentity
revisionValue := revision
target := &model.PagesDeployment{
ProjectID: state.Project.ID,
DeploymentNumber: maxNumber + 1,
Checksum: packageChecksum,
Status: model.PagesDeploymentStatusUploaded,
UploadID: ingestResult.Upload.ID,
FileCount: manifest.FileCount,
TotalSize: manifest.TotalSize,
CreatedBy: actor,
SourceType: state.Source.SourceType,
SourceIdentity: &identity,
SourceRevision: &revisionValue,
SourceLabel: sourceDetailLabel(detail),
SourceMeta: detailJSON,
TriggerType: triggerType,
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(target)
if result.Error != nil {
return nil, false, result.Error
}
if result.RowsAffected == 0 {
return reloadSourceDeploymentTx(tx, state.Project.ID, identity, revision)
}
if err := createSourceDeploymentFilesTx(tx, target.ID, manifest.Files); err != nil {
return nil, false, err
}
return target, false, nil
}
func validSourceDeploymentTrigger(triggerType string) bool {
return triggerType == pagesSourceTriggerManualSync || triggerType == pagesSourceTriggerScheduledAutoUpdate
}
func reloadSourceDeploymentTx(
tx *gorm.DB,
projectID uint,
identity string,
revision string,
) (*model.PagesDeployment, bool, error) {
var target model.PagesDeployment
err := tx.Where(
"project_id = ? AND source_identity = ? AND source_revision = ?",
projectID,
identity,
revision,
).First(&target).Error
return &target, true, err
}
func createSourceDeploymentFilesTx(tx *gorm.DB, deploymentID uint, files []model.PagesDeploymentFile) error {
if len(files) == 0 {
return nil
}
for index := range files {
files[index].DeploymentID = deploymentID
}
return tx.Create(&files).Error
}
func lockSourceDeploymentUploadsTx(
tx *gorm.DB,
target *model.PagesDeployment,
ingestResult upload.IngestResult,
hasIngest bool,
) error {
uploadIDs := []uint64{target.UploadID}
if hasIngest && ingestResult.Upload.ID != 0 && ingestResult.Upload.ID != target.UploadID {
uploadIDs = append(uploadIDs, ingestResult.Upload.ID)
}
slices.Sort(uploadIDs)
var records []model.Upload
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
Where("id IN ?", uploadIDs).
Order("id asc").
Find(&records).Error; err != nil {
return err
}
for index := range records {
if records[index].ID != target.UploadID {
continue
}
if records[index].Status == model.UploadStatusUsed && records[index].Type == upload.ReservedPagesDeploymentType {
return nil
}
break
}
return errSourceFinalFence
}
func activateSourceDeploymentTx(
tx *gorm.DB,
state *sourceCommitState,
target *model.PagesDeployment,
revision string,
detailJSON string,
nextCheckNotBefore *time.Time,
) error {
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", state.Project.ID).
Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil {
return err
}
if err := tx.Model(target).Updates(map[string]any{
pagesDeploymentColumnStatus: model.PagesDeploymentStatusActive,
"activated_at": &state.Now,
}).Error; err != nil {
return err
}
if err := tx.Model(state.Project).Update("active_deployment_id", target.ID).Error; err != nil {
return err
}
finishedAt := sourceCommitNow()
var nextCheckAt any
if state.Source.SourceType == PagesSourceTypeGitHubRelease &&
state.Source.ReleaseSelector == githubReleaseSelectorLatest {
next := nextGitHubCheckAt(finishedAt, state.Source.ID, state.Source.CheckIntervalMinutes)
if nextCheckNotBefore != nil && nextCheckNotBefore.After(next) {
next = nextCheckNotBefore.In(finishedAt.Location())
}
nextCheckAt = &next
}
rows, err := repository.UpdatePagesSourceRuntimeByActiveLeaseTx(
tx,
state.Runtime.SourceID,
state.Runtime.LeaseToken,
finishedAt,
map[string]any{
"last_seen_revision": revision,
"last_seen_detail": detailJSON,
"last_applied_revision": revision,
"last_applied_detail": detailJSON,
sourceRuntimeColumnSyncStatus: pagesSourceStatusIdle,
sourceRuntimeColumnLastError: "",
sourceRuntimeColumnLastCheckedAt: &finishedAt,
"last_synced_at": &finishedAt,
"next_check_at": nextCheckAt,
sourceRuntimeColumnLeaseToken: "",
sourceRuntimeColumnLeaseExpiresAt: nil,
},
)
if err != nil {
return err
}
if rows != 1 {
return errSourceFinalFence
}
return nil
}
func safeRemoteSourceLabel(raw string) string {
value := strings.ReplaceAll(strings.ToValidUTF8(raw, ""), "\\", "/")
value = path.Base(strings.TrimSpace(value))
if value == "." || value == "/" {
value = ""
}
var builder strings.Builder
for _, character := range value {
if character >= 0x20 && character != 0x7f {
builder.WriteRune(character)
}
}
value = strings.TrimSpace(builder.String())
if value == "" {
value = defaultRemoteAssetLabel
}
if len(value) > remoteSourceMaxSafeLabelBytes {
value = value[:remoteSourceMaxSafeLabelBytes]
for !utf8.ValidString(value) {
_, size := utf8.DecodeLastRuneInString(value)
value = value[:len(value)-size]
}
}
return value
}
func sourceSyncResultDetail(outcome *sourceSyncOutcome) string {
if outcome == nil || outcome.Deployment == nil {
return ""
}
payload := map[string]any{
"deployment_id": outcome.Deployment.ID,
"reused": outcome.Reused,
}
encoded, err := json.Marshal(payload)
if err != nil {
return fmt.Sprintf(`{"deployment_id":%d}`, outcome.Deployment.ID)
}
return string(encoded)
}