优化结构
This commit is contained in:
@@ -7,12 +7,15 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
taskbiz "kra/internal/biz/task"
|
||||
)
|
||||
@@ -20,6 +23,10 @@ import (
|
||||
type TaskExecutor struct {
|
||||
tasks *taskbiz.TaskUsecase
|
||||
methods taskbiz.TaskMethodRegistry
|
||||
// orphans tracks method goroutines that outlived their execution timeout.
|
||||
// A method cannot be killed, so the task stays claimed until it returns.
|
||||
orphanMu sync.Mutex
|
||||
orphans map[uint]struct{}
|
||||
}
|
||||
|
||||
func NewTaskExecutorWithRegistry(tasks *taskbiz.TaskUsecase, methods taskbiz.TaskMethodRegistry) *TaskExecutor {
|
||||
@@ -30,7 +37,21 @@ func privateIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
|
||||
}
|
||||
|
||||
// Task HTTP clients are built once and shared: a per-request client would
|
||||
// discard every pooled connection and never release its transport.
|
||||
var (
|
||||
taskHTTPClientPublic = newTaskHTTPClient(false)
|
||||
taskHTTPClientPrivate = newTaskHTTPClient(true)
|
||||
)
|
||||
|
||||
func taskHTTPClient(allowPrivate bool) *http.Client {
|
||||
if allowPrivate {
|
||||
return taskHTTPClientPrivate
|
||||
}
|
||||
return taskHTTPClientPublic
|
||||
}
|
||||
|
||||
func newTaskHTTPClient(allowPrivate bool) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, Control: func(_ string, address string, _ syscall.RawConn) error {
|
||||
if allowPrivate {
|
||||
return nil
|
||||
@@ -48,7 +69,8 @@ func taskHTTPClient(allowPrivate bool) *http.Client {
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
return &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{Proxy: nil, DialContext: dialer.DialContext}}
|
||||
transport := &http.Transport{Proxy: nil, DialContext: dialer.DialContext, MaxIdleConnsPerHost: 4, IdleConnTimeout: 90 * time.Second}
|
||||
return &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
||||
}
|
||||
|
||||
func (e *TaskExecutor) runHTTP(ctx context.Context, task *taskbiz.TimedTask) (string, error) {
|
||||
@@ -104,22 +126,67 @@ func (e *TaskExecutor) runHTTP(ctx context.Context, task *taskbiz.TimedTask) (st
|
||||
return output, nil
|
||||
}
|
||||
|
||||
var errTaskTimeout = errors.New("任务执行超时")
|
||||
var (
|
||||
errTaskTimeout = errors.New("任务执行超时")
|
||||
errTaskOrphaned = errors.New("上一次执行超时后仍在运行, 本次执行已跳过")
|
||||
)
|
||||
|
||||
func truncateTaskText(value string) string {
|
||||
const limit = 4000
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return value[:limit] + "...(截断)"
|
||||
// Cut on a rune boundary: slicing raw bytes would split multi-byte text
|
||||
// (Chinese output in particular) into an invalid UTF-8 sequence.
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(value[cut]) {
|
||||
cut--
|
||||
}
|
||||
return value[:cut] + "...(截断)"
|
||||
}
|
||||
|
||||
func (e *TaskExecutor) recordTaskLog(ctx context.Context, log *taskbiz.TimedTaskLog) {
|
||||
defer func() { _ = recover() }()
|
||||
if e == nil || e.tasks == nil || log == nil {
|
||||
return
|
||||
}
|
||||
_ = e.tasks.RecordTaskLog(ctx, log)
|
||||
// This runs from Run's deferred block, so a panic here would replace the
|
||||
// task's own result with a log-writing panic. Recover, but report it: a
|
||||
// silently swallowed panic hides the fact that no execution log was stored.
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
slog.Default().Error("recording the timed task log panicked", "mod", "timedTask", "task_id", log.TaskID, "task_name", log.TaskName, "panic", recovered)
|
||||
}
|
||||
}()
|
||||
if err := e.tasks.RecordTaskLog(ctx, log); err != nil {
|
||||
slog.Default().Error("recording the timed task log failed", "mod", "timedTask", "task_id", log.TaskID, "task_name", log.TaskName, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// methodOrphaned reports whether a previous invocation of the task's method is
|
||||
// still running after its execution timeout elapsed.
|
||||
func (e *TaskExecutor) methodOrphaned(id uint) bool {
|
||||
e.orphanMu.Lock()
|
||||
defer e.orphanMu.Unlock()
|
||||
_, orphaned := e.orphans[id]
|
||||
return orphaned
|
||||
}
|
||||
|
||||
// markMethodOrphan keeps the task claimed until the abandoned goroutine
|
||||
// returns. Without it the scheduler's running-set is cleared as soon as the
|
||||
// timeout fires, so the next trigger would run the same method concurrently.
|
||||
func (e *TaskExecutor) markMethodOrphan(id uint, done <-chan error) {
|
||||
e.orphanMu.Lock()
|
||||
if e.orphans == nil {
|
||||
e.orphans = map[uint]struct{}{}
|
||||
}
|
||||
e.orphans[id] = struct{}{}
|
||||
e.orphanMu.Unlock()
|
||||
go func() {
|
||||
<-done
|
||||
e.orphanMu.Lock()
|
||||
delete(e.orphans, id)
|
||||
e.orphanMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) error {
|
||||
@@ -140,6 +207,9 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e
|
||||
if !ok {
|
||||
return fmt.Errorf("方法 %s 未注册(需通过 platform/task.Registry 注册)", task.MethodName)
|
||||
}
|
||||
if e.methodOrphaned(task.ID) {
|
||||
return errTaskOrphaned
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
@@ -158,6 +228,7 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e
|
||||
}
|
||||
return err
|
||||
case <-runCtx.Done():
|
||||
e.markMethodOrphan(task.ID, done)
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
return errTaskTimeout
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user