Files
dingtalk-workspace-cli/pkg/asynctask/asynctask.go

254 lines
7.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package asynctask 封装"提交 → 渐进式退避轮询 → (可选) 下载"模式,复用给
// doc export / sheet export / aitable export 三个产品。
//
// 设计要点:
//
// 1. 调用方只需提供"提交"和"查询状态"两个回调,循环逻辑由本包处理
// 2. 渐进式退避(1s/2s/5s/10s/15s/30s/30s...,可自定义)
// 3. 默认 5 分钟超时,超时返回 jobId 让用户用 `... get --job-id <ID>` 续等
// 4. 状态机统一:PROCESSING/PENDING → SUCCESS → 拿 downloadUrl
// ↓
// FAILED → 返回 error
// 5. 可选 PUT 下载:传入 OutputPath 时本包负责 HTTP GET 落盘
package asynctask
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
)
// Status 表示异步任务的状态。
type Status string
const (
StatusProcessing Status = "PROCESSING"
StatusPending Status = "PENDING"
StatusSuccess Status = "SUCCESS"
StatusFailed Status = "FAILED"
StatusTimeout Status = "TIMEOUT" // 本地超时(非服务端状态)
)
// SubmitFunc 是"提交任务"回调,返回 jobId 或 error。
type SubmitFunc func(ctx context.Context) (jobID string, err error)
// QueryResult 是 QueryFunc 的返回。
//
// - Status 状态枚举
// - DownloadURL 服务端返回的下载链接(仅 SUCCESS 时有效)
// - Message 失败原因 / 进度描述
// - Raw 原始响应(用于调用方在最终输出里附带)
type QueryResult struct {
Status Status
DownloadURL string
Message string
Raw map[string]any
}
// QueryFunc 是"查询任务状态"回调。
type QueryFunc func(ctx context.Context, jobID string) (QueryResult, error)
// Options 控制 WaitWithBackoff 的行为。
type Options struct {
// Backoff 是页间退避序列(毫秒)。零值或空使用 DefaultBackoff。
// 序列耗尽后保持最后一个间隔继续轮询。
Backoff []time.Duration
// Timeout 是整体轮询上限。零值使用 DefaultTimeout(5 分钟)。
Timeout time.Duration
// ProgressFn 是每轮查询后的进度回调(可选)。用于打印 "[2/30] processing..."
// 之类的 stderr 提示。
ProgressFn func(attempt int, status Status, elapsed time.Duration)
}
var (
// DefaultBackoff 是渐进式退避序列:1s/2s/5s/10s/15s/30s,之后稳定 30s。
// 与悟空 wukong/products/aitable.go / doc.go 对齐。
DefaultBackoff = []time.Duration{
1 * time.Second,
2 * time.Second,
5 * time.Second,
10 * time.Second,
15 * time.Second,
30 * time.Second,
}
// DefaultTimeout 是默认超时上限。
DefaultTimeout = 5 * time.Minute
)
// FinalResult 是 WaitWithBackoff 的最终返回。
type FinalResult struct {
JobID string
Status Status
DownloadURL string
Message string
Raw map[string]any
Attempts int
Elapsed time.Duration
}
// Submit 提交任务并轮询直到完成 / 失败 / 超时。
//
// 行为:
// 1. 调用 submitFn 拿到 jobID
// 2. 进入轮询循环:按 backoff 序列等待 → 调 queryFn → 检查状态
// 3. SUCCESS → 返回 FinalResult.Status = SUCCESS + downloadURL
// 4. FAILED → 返回 FinalResult.Status = FAILED + Message(不抛 error,让调用方决定)
// 5. PROCESSING/PENDING → 继续轮询
// 6. 超过 Timeout → 返回 FinalResult.Status = TIMEOUT + JobID(调用方可提示用户用 ... get --job-id <ID>)
// 7. ctx 取消 → 返回带 ctx.Err() 的 error
func Submit(ctx context.Context, submitFn SubmitFunc, queryFn QueryFunc, opts Options) (FinalResult, error) {
jobID, err := submitFn(ctx)
if err != nil {
return FinalResult{}, fmt.Errorf("submit failed: %w", err)
}
if jobID == "" {
return FinalResult{}, errors.New("submit returned empty jobID")
}
return Resume(ctx, jobID, queryFn, opts)
}
// Resume 接续轮询已知 jobID 的任务(提交步骤已完成)。
// 用于 `... export get --job-id <ID>` 兜底命令。
func Resume(ctx context.Context, jobID string, queryFn QueryFunc, opts Options) (FinalResult, error) {
backoff := opts.Backoff
if len(backoff) == 0 {
backoff = DefaultBackoff
}
timeout := opts.Timeout
if timeout == 0 {
timeout = DefaultTimeout
}
deadline := time.Now().Add(timeout)
start := time.Now()
attempt := 0
var lastErr error
for {
attempt++
// 超时检查前置:保证 Timeout < backoff[0] 也能正常返回 TIMEOUT,不会卡在 sleep
if time.Now().After(deadline) {
msg := fmt.Sprintf("task did not complete within %v; resume with the original jobID %s", timeout, jobID)
if lastErr != nil {
msg += fmt.Sprintf(" (last query error: %v)", lastErr)
}
return FinalResult{
JobID: jobID,
Status: StatusTimeout,
Message: msg,
Attempts: attempt - 1,
Elapsed: time.Since(start),
}, nil
}
// 等待间隔(首次也等,避免提交后立即查询,给服务端处理时间)
// sleep 长度上限取 deadline 剩余时间,避免 sleep 后再发现已超时
interval := backoff[len(backoff)-1] // 序列耗尽后用最后一个
if attempt-1 < len(backoff) {
interval = backoff[attempt-1]
}
if remain := time.Until(deadline); remain > 0 && interval > remain {
interval = remain
}
select {
case <-ctx.Done():
return FinalResult{JobID: jobID, Attempts: attempt, Elapsed: time.Since(start)}, ctx.Err()
case <-time.After(interval):
}
qr, err := queryFn(ctx, jobID)
if err != nil {
// 单次查询失败不立即放弃,下一轮重试;保存 error 给最后兜底(superseded
// by 下次成功;timeout 时会把它附到 Message 帮助排查)
lastErr = err
if opts.ProgressFn != nil {
opts.ProgressFn(attempt, "", time.Since(start))
}
continue
}
lastErr = nil
if opts.ProgressFn != nil {
opts.ProgressFn(attempt, qr.Status, time.Since(start))
}
switch qr.Status {
case StatusSuccess:
return FinalResult{
JobID: jobID,
Status: StatusSuccess,
DownloadURL: qr.DownloadURL,
Message: qr.Message,
Raw: qr.Raw,
Attempts: attempt,
Elapsed: time.Since(start),
}, nil
case StatusFailed:
return FinalResult{
JobID: jobID,
Status: StatusFailed,
Message: qr.Message,
Raw: qr.Raw,
Attempts: attempt,
Elapsed: time.Since(start),
}, nil
case StatusProcessing, StatusPending, "":
// 继续轮询(空字符串容错:部分上游可能 status 缺失)
continue
default:
// 未知状态:当作 processing 继续等
continue
}
}
}
// Download 把 downloadURL 的内容 GET 到本地 outputPath。
// 单独抽出来便于调用方在不调 Submit 时也能复用(例如 sheet export 已有 jobId 的场景)。
func Download(ctx context.Context, downloadURL, outputPath string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return fmt.Errorf("build download request: %w", err)
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("download HTTP %d: %s", resp.StatusCode, string(body))
}
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output file: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
return fmt.Errorf("write output: %w", err)
}
return nil
}