mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 17:09:41 +00:00
161 lines
4.8 KiB
Go
161 lines
4.8 KiB
Go
package githubmirror
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Options configures execution, not URL persistence. Discard releases results
|
|
// from failed attempts and successful race losers, including partial results
|
|
// returned together with an error. The caller owns the winning result.
|
|
type Options[T any] struct {
|
|
Kind Kind
|
|
// Timeout limits each attempt. Zero uses 30 seconds for resources and no
|
|
// overall deadline for repositories, whose transport owns idle timeouts.
|
|
Timeout time.Duration
|
|
Discard func(T)
|
|
}
|
|
|
|
// Attempt must honor ctx and return a completed, validated result. Its context
|
|
// is canceled after it returns, so live response streams must not be returned.
|
|
// Return any partially acquired resources along with errors so Discard can
|
|
// release them. Failed attempts must not mutate persistent business state.
|
|
type Attempt[T any] func(context.Context, string) (T, error)
|
|
|
|
const defaultTimeout = 30 * time.Second
|
|
|
|
func prepare[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) ([]string, time.Duration, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if raw == "" {
|
|
return nil, 0, errors.New("empty GitHub source URL")
|
|
}
|
|
if options.Kind != Resource && options.Kind != Repository {
|
|
return nil, 0, errors.New("invalid GitHub resource kind")
|
|
}
|
|
if options.Timeout < 0 {
|
|
return nil, 0, errors.New("invalid GitHub attempt timeout")
|
|
}
|
|
if attempt == nil {
|
|
return nil, 0, errors.New("GitHub mirror attempt is required")
|
|
}
|
|
if config != nil {
|
|
if err := config.Validate(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
timeout := options.Timeout
|
|
if timeout == 0 && options.Kind == Resource {
|
|
timeout = defaultTimeout
|
|
}
|
|
return Candidates(config, raw, options.Kind), timeout, nil
|
|
}
|
|
func invoke[T any](ctx context.Context, timeout time.Duration, candidate string, attempt Attempt[T]) (value T, err error) {
|
|
var cancel context.CancelFunc
|
|
if timeout > 0 {
|
|
ctx, cancel = context.WithTimeout(ctx, timeout)
|
|
} else {
|
|
ctx, cancel = context.WithCancel(ctx)
|
|
}
|
|
defer cancel()
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
err = fmt.Errorf("GitHub mirror attempt panicked: %v", recovered)
|
|
}
|
|
}()
|
|
value, err = attempt(ctx, candidate)
|
|
if err == nil {
|
|
err = ctx.Err()
|
|
}
|
|
return
|
|
}
|
|
func discard[T any](options Options[T], value T) {
|
|
if options.Discard != nil {
|
|
options.Discard(value)
|
|
}
|
|
}
|
|
|
|
// Race executes the official URL and eligible mirrors concurrently. The first
|
|
// completed successful result wins; losing attempts are canceled and drained
|
|
// before return so no temporary files or other resources outlive the call.
|
|
// HTTP status/content validation is the attempt's responsibility.
|
|
func Race[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
|
|
var zero T
|
|
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
raceCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
type outcome struct {
|
|
index int
|
|
value T
|
|
err error
|
|
}
|
|
outcomes := make(chan outcome, len(candidates))
|
|
for index, candidate := range candidates {
|
|
go func(index int, candidate string) {
|
|
value, err := invoke(raceCtx, timeout, candidate, attempt)
|
|
outcomes <- outcome{index, value, err}
|
|
}(index, candidate)
|
|
}
|
|
var winner T
|
|
won := false
|
|
failures := make([]error, len(candidates))
|
|
for range candidates {
|
|
outcome := <-outcomes
|
|
if !won && outcome.err == nil && ctx.Err() == nil {
|
|
winner = outcome.value
|
|
won = true
|
|
cancel()
|
|
} else {
|
|
discard(options, outcome.value)
|
|
if outcome.err != nil {
|
|
failures[outcome.index] = fmt.Errorf("GitHub source %d: %w", outcome.index+1, outcome.err)
|
|
}
|
|
if ctx.Err() != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
if won {
|
|
discard(options, winner)
|
|
}
|
|
return zero, err
|
|
}
|
|
if won {
|
|
return winner, nil
|
|
}
|
|
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
|
|
}
|
|
|
|
// Fallback executes the official URL first, then eligible mirrors in their
|
|
// configured order. It stops immediately on success and cleans every failure.
|
|
func Fallback[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
|
|
var zero T
|
|
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
failures := make([]error, 0, len(candidates))
|
|
for index, candidate := range candidates {
|
|
if err := ctx.Err(); err != nil {
|
|
return zero, err
|
|
}
|
|
value, err := invoke(ctx, timeout, candidate, attempt)
|
|
if err == nil && ctx.Err() == nil {
|
|
return value, nil
|
|
}
|
|
discard(options, value)
|
|
if parentErr := ctx.Err(); parentErr != nil {
|
|
return zero, parentErr
|
|
}
|
|
failures = append(failures, fmt.Errorf("GitHub source %d: %w", index+1, err))
|
|
}
|
|
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
|
|
}
|