HH-807: harden runtime coordination (#21)

This commit is contained in:
2026-08-31 19:37:17 +08:00
parent 1d9fd9f0e0
commit 024bf14448
7 changed files with 937 additions and 45 deletions
+61 -3
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
@@ -62,6 +63,8 @@ var (
func ValidImageVersion(version string) bool { return imageVersionPattern.MatchString(version) }
func ValidNetworkExitID(id string) bool { return exitIDPattern.MatchString(id) }
var (
aliasPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
gatewayNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
@@ -71,8 +74,9 @@ var (
)
type Store struct {
db *sql.DB
notify taskstate.Notifier
db *sql.DB
lockAdmission chan struct{}
notify taskstate.Notifier
}
// Gateway 是平台注册的 docker-gateway 实例;Token 由平台生成,明文存储供页面复制(开发阶段约定)。
@@ -116,7 +120,9 @@ func Open(ctx context.Context, databaseURL string) (*Store, error) {
db.Close()
return nil, errors.New("connect to hub database")
}
store := &Store{db: db}
// LockResources keeps one connection until the lifecycle operation finishes.
// Admit at most half the pool so those operations can still open nested DB calls.
store := &Store{db: db, lockAdmission: make(chan struct{}, 5)}
if err := store.migrate(ctx); err != nil {
db.Close()
return nil, err
@@ -137,6 +143,58 @@ func (s *Store) notifyTransitions(transitions []taskstate.Transition) {
}
}
// LockResources serializes lifecycle state across control-plane replicas. The
// transaction carries no data changes; rolling it back only releases the locks.
func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error) {
for _, alias := range aliases {
if !aliasPattern.MatchString(alias) {
return nil, ErrInvalid
}
}
for _, id := range exitIDs {
if !exitIDPattern.MatchString(id) {
return nil, ErrInvalid
}
}
for _, version := range imageVersions {
if !imageVersionPattern.MatchString(version) {
return nil, ErrInvalid
}
}
if len(aliases)+len(exitIDs)+len(imageVersions) == 0 {
return func() {}, nil
}
select {
case s.lockAdmission <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
<-s.lockAdmission
return nil, errors.New("begin resource lock")
}
resources := []struct {
namespace int
keys []string
}{{1542738013, aliases}, {1542738015, exitIDs}, {1542738014, imageVersions}}
for _, resource := range resources {
keys := append([]string(nil), resource.keys...)
sort.Strings(keys)
for _, key := range keys {
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1, hashtext($2))`, resource.namespace, key); err != nil {
_ = tx.Rollback()
<-s.lockAdmission
return nil, errors.New("lock resource")
}
}
}
return func() {
_ = tx.Rollback()
<-s.lockAdmission
}, nil
}
func (s *Store) migrate(ctx context.Context) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
+121
View File
@@ -4,16 +4,137 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"git.ipao.vip/rogee/creator-hub/internal/taskstate"
)
func TestEnvironmentLocksCoordinateAcrossStoreInstances(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
testURL := isolatedDatabaseURL(t, databaseURL)
first := openFullyMigratedHub(t, ctx, testURL)
t.Cleanup(func() { _ = first.Close() })
second, err := Open(ctx, testURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = second.Close() })
unlockFirst, err := first.LockResources(ctx, []string{"account-a"}, nil, nil)
if err != nil {
t.Fatal(err)
}
firstReleased := false
defer func() {
if !firstReleased {
unlockFirst()
}
}()
differentAlias, err := second.LockResources(ctx, []string{"account-b"}, nil, nil)
if err != nil {
t.Fatalf("different aliases must not share a lock: %v", err)
}
differentAlias()
acquired := make(chan func(), 1)
errors := make(chan error, 1)
started := make(chan struct{})
go func() {
close(started)
unlock, lockErr := second.LockResources(ctx, []string{"account-a"}, nil, nil)
if lockErr != nil {
errors <- lockErr
return
}
acquired <- unlock
}()
<-started
select {
case unlock := <-acquired:
unlock()
t.Fatal("same alias lock did not block across Store instances")
case err := <-errors:
t.Fatal(err)
case <-time.After(50 * time.Millisecond):
}
unlockFirst()
firstReleased = true
select {
case unlock := <-acquired:
unlock()
case err := <-errors:
t.Fatal(err)
case <-time.After(time.Second):
t.Fatal("same alias lock was not released")
}
}
func TestResourceLocksReserveConnectionsForLifecycleQueries(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL))
t.Cleanup(func() { _ = store.Close() })
const workers = 10
var acquired atomic.Int32
startQueries := make(chan struct{})
done := make(chan error, workers)
for worker := 0; worker < workers; worker++ {
go func(worker int) {
unlock, err := store.LockResources(ctx, []string{fmt.Sprintf("account-%d", worker)}, nil, nil)
if err != nil {
done <- err
return
}
defer unlock()
acquired.Add(1)
<-startQueries
_, err = store.ListEnvs(ctx)
done <- err
}(worker)
}
deadline := time.NewTimer(100 * time.Millisecond)
ticker := time.NewTicker(time.Millisecond)
for acquired.Load() < workers {
select {
case <-ticker.C:
case <-deadline.C:
goto release
}
}
release:
ticker.Stop()
if !deadline.Stop() {
select {
case <-deadline.C:
default:
}
}
close(startQueries)
for worker := 0; worker < workers; worker++ {
if err := <-done; err != nil {
t.Fatalf("locked lifecycle query %d did not complete: %v", worker, err)
}
}
}
func TestFingerprintArgsFollowUpstreamCommandLineContract(t *testing.T) {
full := Fingerprint{
Seed: 2024, Platform: "windows", PlatformVersion: "11.0.0",