This commit is contained in:
209
internal/task/export_common.go
Normal file
209
internal/task/export_common.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
exportLockTTL = 5 * time.Minute
|
||||
exportShardLockTTL = 10 * time.Minute
|
||||
exportFinalizeLockTTL = 2 * time.Minute
|
||||
)
|
||||
|
||||
// ExportDispatchPayload 导出 dispatch 任务载荷。
|
||||
type ExportDispatchPayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}
|
||||
|
||||
// ExportShardPayload 导出 shard 任务载荷。
|
||||
type ExportShardPayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
ShardID uint `json:"shard_id"`
|
||||
}
|
||||
|
||||
// ExportFinalizePayload 导出 finalize 任务载荷。
|
||||
type ExportFinalizePayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}
|
||||
|
||||
func enqueueTask(ctx context.Context, client *asynq.Client, taskType string, payload any, opts ...asynq.Option) error {
|
||||
if client == nil {
|
||||
return errors.New("asynq client 未初始化")
|
||||
}
|
||||
|
||||
bytes, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task := asynq.NewTask(taskType, bytes, opts...)
|
||||
_, err = client.EnqueueContext(ctx, task)
|
||||
return err
|
||||
}
|
||||
|
||||
func tryAcquireLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (bool, error) {
|
||||
if rdb == nil {
|
||||
return true, nil
|
||||
}
|
||||
return rdb.SetNX(ctx, key, "1", ttl).Result()
|
||||
}
|
||||
|
||||
func releaseLock(ctx context.Context, rdb *redis.Client, key string) {
|
||||
if rdb == nil {
|
||||
return
|
||||
}
|
||||
_, _ = rdb.Del(ctx, key).Result()
|
||||
}
|
||||
|
||||
func isFinalRetry(ctx context.Context) bool {
|
||||
retryCount, ok := asynq.GetRetryCount(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
maxRetry, ok := asynq.GetMaxRetry(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return retryCount >= maxRetry
|
||||
}
|
||||
|
||||
func writeExportFile(format string, headers []string, rows [][]string) (string, int64, error) {
|
||||
switch format {
|
||||
case constants.ExportTaskFormatCSV:
|
||||
return writeCSVFile(headers, rows)
|
||||
case constants.ExportTaskFormatXLSX:
|
||||
return writeXLSXFile(headers, rows)
|
||||
default:
|
||||
return "", 0, fmt.Errorf("不支持的导出格式: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCSVFile(headers []string, rows [][]string) (string, int64, error) {
|
||||
file, err := os.CreateTemp("", "export-*.csv")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := csv.NewWriter(file)
|
||||
if err := writer.Write(headers); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := writer.Write(row); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if err := file.Sync(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return file.Name(), stat.Size(), nil
|
||||
}
|
||||
|
||||
func writeXLSXFile(headers []string, rows [][]string) (string, int64, error) {
|
||||
file := excelize.NewFile()
|
||||
sheet := file.GetSheetName(file.GetActiveSheetIndex())
|
||||
|
||||
streamWriter, err := file.NewStreamWriter(sheet)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
headerValues := make([]interface{}, 0, len(headers))
|
||||
for _, item := range headers {
|
||||
headerValues = append(headerValues, item)
|
||||
}
|
||||
if err := streamWriter.SetRow("A1", headerValues); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
for i, row := range rows {
|
||||
axis, err := excelize.CoordinatesToCellName(1, i+2)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
values := make([]interface{}, 0, len(row))
|
||||
for _, item := range row {
|
||||
values = append(values, item)
|
||||
}
|
||||
if err := streamWriter.SetRow(axis, values); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := streamWriter.Flush(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
path := filepath.Join(os.TempDir(), fmt.Sprintf("export-%d.xlsx", time.Now().UnixNano()))
|
||||
if err := file.SaveAs(path); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return path, stat.Size(), nil
|
||||
}
|
||||
|
||||
func uploadExportFile(ctx context.Context, storageSvc *storage.Service, localPath, fileName string) (string, error) {
|
||||
if storageSvc == nil || storageSvc.Provider() == nil {
|
||||
return "", errors.New("对象存储服务未配置")
|
||||
}
|
||||
|
||||
key, err := storageSvc.GenerateFileKey("export", fileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
contentType := "application/octet-stream"
|
||||
if filepath.Ext(fileName) == ".csv" {
|
||||
contentType = "text/csv"
|
||||
}
|
||||
if filepath.Ext(fileName) == ".xlsx" {
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
}
|
||||
|
||||
if err := storageSvc.Provider().Upload(ctx, key, file, contentType); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func parseExportPayload[T any](payload []byte, target *T) error {
|
||||
return sonic.Unmarshal(payload, target)
|
||||
}
|
||||
255
internal/task/export_dispatch.go
Normal file
255
internal/task/export_dispatch.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/exporter"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// ExportDispatchHandler 导出 dispatch 处理器。
|
||||
type ExportDispatchHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
taskStore *postgres.ExportTaskStore
|
||||
shardStore *postgres.ExportShardTaskStore
|
||||
asynqClient *asynq.Client
|
||||
sceneRegistry *exporter.Registry
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewExportDispatchHandler 创建导出 dispatch 处理器。
|
||||
func NewExportDispatchHandler(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
taskStore *postgres.ExportTaskStore,
|
||||
shardStore *postgres.ExportShardTaskStore,
|
||||
asynqClient *asynq.Client,
|
||||
sceneRegistry *exporter.Registry,
|
||||
logger *zap.Logger,
|
||||
) *ExportDispatchHandler {
|
||||
return &ExportDispatchHandler{
|
||||
db: db,
|
||||
redis: redis,
|
||||
taskStore: taskStore,
|
||||
shardStore: shardStore,
|
||||
asynqClient: asynqClient,
|
||||
sceneRegistry: sceneRegistry,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleExportDispatch 处理导出 dispatch 任务。
|
||||
func (h *ExportDispatchHandler) HandleExportDispatch(ctx context.Context, task *asynq.Task) error {
|
||||
var payload ExportDispatchPayload
|
||||
if err := parseExportPayload(task.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析导出 dispatch 任务载荷失败", zap.Error(err))
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
lockKey := constants.RedisExportDispatchLockKey(payload.TaskID)
|
||||
locked, err := tryAcquireLock(ctx, h.redis, lockKey, exportLockTTL)
|
||||
if err != nil {
|
||||
h.logger.Error("获取导出 dispatch 锁失败", zap.Uint("task_id", payload.TaskID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if !locked {
|
||||
h.logger.Info("导出 dispatch 已在执行,跳过", zap.Uint("task_id", payload.TaskID))
|
||||
return nil
|
||||
}
|
||||
defer releaseLock(ctx, h.redis, lockKey)
|
||||
|
||||
exportTask, err := h.taskStore.GetByIDForWorker(ctx, payload.TaskID)
|
||||
if err != nil {
|
||||
h.logger.Error("查询导出任务失败", zap.Uint("task_id", payload.TaskID), zap.Error(err))
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
if exportTask.Status == constants.ExportTaskStatusCompleted || exportTask.Status == constants.ExportTaskStatusFailed || exportTask.Status == constants.ExportTaskStatusCancelled {
|
||||
return nil
|
||||
}
|
||||
|
||||
updater := dispatchUpdater(exportTask)
|
||||
|
||||
if exportTask.CancelRequested {
|
||||
if err := h.shardStore.CancelByTaskID(ctx, exportTask.ID, updater, "任务已取消"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.taskStore.MarkCancelledByWorker(ctx, exportTask.ID, updater, "任务已取消"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
started, err := h.taskStore.TryMarkProcessingIfPending(ctx, exportTask.ID, updater)
|
||||
if err != nil {
|
||||
h.logger.Error("更新导出任务状态为处理中失败", zap.Uint("task_id", exportTask.ID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if !started {
|
||||
if exportTask.Status != constants.ExportTaskStatusProcessing {
|
||||
return nil
|
||||
}
|
||||
existingShards, err := h.shardStore.ListByTaskID(ctx, exportTask.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existingShards) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := h.enqueueShardTasks(ctx, exportTask, existingShards); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.enqueueFinalizeTask(ctx, exportTask.ID)
|
||||
}
|
||||
|
||||
strategy, ok := h.sceneRegistry.Get(exportTask.Scene)
|
||||
if !ok {
|
||||
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "导出场景未注册")
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
shards, totalRows, err := h.buildShards(ctx, exportTask, strategy, updater)
|
||||
if err != nil {
|
||||
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "构建导出分片失败")
|
||||
h.logger.Error("构建导出分片失败", zap.Uint("task_id", exportTask.ID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.persistShardsAndPlan(ctx, exportTask.ID, updater, shards, totalRows); err != nil {
|
||||
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "保存导出分片失败")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.enqueueShardTasks(ctx, exportTask, shards); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.enqueueFinalizeTask(ctx, exportTask.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.logger.Info("导出 dispatch 完成",
|
||||
zap.Uint("task_id", exportTask.ID),
|
||||
zap.Uint("total_rows", totalRows),
|
||||
zap.Int("total_shards", len(shards)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ExportDispatchHandler) buildShards(ctx context.Context, task *model.ExportTask, strategy exporter.SceneStrategy, updater uint) ([]*model.ExportShardTask, uint, error) {
|
||||
shards := make([]*model.ExportShardTask, 0)
|
||||
totalRows := uint(0)
|
||||
afterID := uint64(0)
|
||||
shardNo := 1
|
||||
|
||||
for {
|
||||
if task.CancelRequested {
|
||||
break
|
||||
}
|
||||
|
||||
boundary, err := strategy.NextShardBoundary(ctx, task, afterID, constants.ExportDefaultShardSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if boundary == nil {
|
||||
break
|
||||
}
|
||||
|
||||
rowCount := boundary.RowCount
|
||||
if rowCount < 0 {
|
||||
rowCount = 0
|
||||
}
|
||||
|
||||
totalRows += uint(rowCount)
|
||||
shard := &model.ExportShardTask{
|
||||
TaskID: task.ID,
|
||||
ShardNo: shardNo,
|
||||
Status: constants.ExportShardStatusPending,
|
||||
CursorStart: boundary.StartID,
|
||||
CursorEnd: boundary.EndID,
|
||||
RowCount: rowCount,
|
||||
}
|
||||
shard.Creator = updater
|
||||
shard.Updater = updater
|
||||
shards = append(shards, shard)
|
||||
|
||||
afterID = boundary.EndID
|
||||
shardNo++
|
||||
}
|
||||
|
||||
return shards, totalRows, nil
|
||||
}
|
||||
|
||||
func (h *ExportDispatchHandler) persistShardsAndPlan(ctx context.Context, taskID uint, updater uint, shards []*model.ExportShardTask, totalRows uint) error {
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
txShardStore := postgres.NewExportShardTaskStore(tx, h.redis)
|
||||
txTaskStore := postgres.NewExportTaskStore(tx, h.redis)
|
||||
|
||||
if err := txShardStore.CreateBatch(ctx, shards); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txTaskStore.UpdateDispatchPlan(ctx, taskID, totalRows, uint(len(shards)), updater); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ExportDispatchHandler) enqueueShardTasks(ctx context.Context, task *model.ExportTask, shards []*model.ExportShardTask) error {
|
||||
for _, shard := range shards {
|
||||
payload := ExportShardPayload{TaskID: task.ID, ShardID: shard.ID}
|
||||
err := enqueueTask(
|
||||
ctx,
|
||||
h.asynqClient,
|
||||
constants.TaskTypeExportShard,
|
||||
payload,
|
||||
asynq.Queue(constants.QueueLow),
|
||||
asynq.MaxRetry(constants.ExportShardRetryMax),
|
||||
asynq.Timeout(constants.ExportShardTaskTimeout),
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("导出分片任务入队失败",
|
||||
zap.Uint("task_id", task.ID),
|
||||
zap.Uint("shard_id", shard.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("enqueue shard task failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ExportDispatchHandler) enqueueFinalizeTask(ctx context.Context, taskID uint) error {
|
||||
return enqueueTask(
|
||||
ctx,
|
||||
h.asynqClient,
|
||||
constants.TaskTypeExportFinalize,
|
||||
ExportFinalizePayload{TaskID: taskID},
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(constants.ExportFinalizeRetryMax),
|
||||
asynq.Timeout(constants.ExportFinalizeTaskTimeout),
|
||||
asynq.ProcessIn(5*time.Second),
|
||||
)
|
||||
}
|
||||
|
||||
func dispatchUpdater(task *model.ExportTask) uint {
|
||||
if task.CreatorUserID > 0 {
|
||||
return task.CreatorUserID
|
||||
}
|
||||
if task.Creator > 0 {
|
||||
return task.Creator
|
||||
}
|
||||
return 0
|
||||
}
|
||||
158
internal/task/export_finalize.go
Normal file
158
internal/task/export_finalize.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/exporter"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||
)
|
||||
|
||||
// ExportFinalizeHandler 导出 finalize 处理器。
|
||||
type ExportFinalizeHandler struct {
|
||||
redis *redis.Client
|
||||
taskStore *postgres.ExportTaskStore
|
||||
shardStore *postgres.ExportShardTaskStore
|
||||
storageSvc *storage.Service
|
||||
sceneRegistry *exporter.Registry
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewExportFinalizeHandler 创建导出 finalize 处理器。
|
||||
func NewExportFinalizeHandler(
|
||||
redis *redis.Client,
|
||||
taskStore *postgres.ExportTaskStore,
|
||||
shardStore *postgres.ExportShardTaskStore,
|
||||
storageSvc *storage.Service,
|
||||
sceneRegistry *exporter.Registry,
|
||||
logger *zap.Logger,
|
||||
) *ExportFinalizeHandler {
|
||||
return &ExportFinalizeHandler{
|
||||
redis: redis,
|
||||
taskStore: taskStore,
|
||||
shardStore: shardStore,
|
||||
storageSvc: storageSvc,
|
||||
sceneRegistry: sceneRegistry,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleExportFinalize 处理导出 finalize 任务。
|
||||
func (h *ExportFinalizeHandler) HandleExportFinalize(ctx context.Context, task *asynq.Task) error {
|
||||
var payload ExportFinalizePayload
|
||||
if err := parseExportPayload(task.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析导出 finalize 任务载荷失败", zap.Error(err))
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
lockKey := constants.RedisExportFinalizeLockKey(payload.TaskID)
|
||||
locked, err := tryAcquireLock(ctx, h.redis, lockKey, exportFinalizeLockTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !locked {
|
||||
return nil
|
||||
}
|
||||
defer releaseLock(ctx, h.redis, lockKey)
|
||||
|
||||
exportTask, err := h.taskStore.GetByIDForWorker(ctx, payload.TaskID)
|
||||
if err != nil {
|
||||
h.logger.Error("查询导出任务失败", zap.Uint("task_id", payload.TaskID), zap.Error(err))
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
if exportTask.Status == constants.ExportTaskStatusCompleted || exportTask.Status == constants.ExportTaskStatusFailed || exportTask.Status == constants.ExportTaskStatusCancelled {
|
||||
return nil
|
||||
}
|
||||
|
||||
updater := dispatchUpdater(exportTask)
|
||||
if exportTask.CancelRequested {
|
||||
if err := h.shardStore.CancelByTaskID(ctx, exportTask.ID, updater, "任务已取消"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.taskStore.MarkCancelledByWorker(ctx, exportTask.ID, updater, "任务已取消"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
unfinishedCount, err := h.shardStore.CountUnfinishedByTaskID(ctx, exportTask.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if unfinishedCount > 0 {
|
||||
return fmt.Errorf("分片任务尚未完成: %d", unfinishedCount)
|
||||
}
|
||||
|
||||
failedCount, err := h.shardStore.CountByTaskIDAndStatus(ctx, exportTask.ID, constants.ExportShardStatusFailed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if failedCount > 0 {
|
||||
if err := h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "存在失败分片,导出任务失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
strategy, ok := h.sceneRegistry.Get(exportTask.Scene)
|
||||
if !ok {
|
||||
if err := h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "导出场景未注册"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
shards, err := h.shardStore.ListByTaskID(ctx, exportTask.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0)
|
||||
for _, shard := range shards {
|
||||
if shard.Status != constants.ExportShardStatusSuccess {
|
||||
continue
|
||||
}
|
||||
currentRows, err := strategy.QueryRows(ctx, exportTask, shard.CursorStart, shard.CursorEnd)
|
||||
if err != nil {
|
||||
return h.handleFinalizeError(ctx, exportTask.ID, updater, "读取分片数据失败", err)
|
||||
}
|
||||
rows = append(rows, currentRows...)
|
||||
}
|
||||
|
||||
localPath, fileSize, err := writeExportFile(exportTask.Format, strategy.Headers(), rows)
|
||||
if err != nil {
|
||||
return h.handleFinalizeError(ctx, exportTask.ID, updater, "生成汇总文件失败", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(localPath) }()
|
||||
|
||||
fileName := fmt.Sprintf("%s.%s", exportTask.TaskNo, exportTask.Format)
|
||||
fileKey, err := uploadExportFile(ctx, h.storageSvc, localPath, fileName)
|
||||
if err != nil {
|
||||
return h.handleFinalizeError(ctx, exportTask.ID, updater, "上传汇总文件失败", err)
|
||||
}
|
||||
|
||||
if err := h.taskStore.MarkCompleted(ctx, exportTask.ID, updater, fileKey, fileSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ExportFinalizeHandler) handleFinalizeError(ctx context.Context, taskID uint, updater uint, prefix string, err error) error {
|
||||
if isFinalRetry(ctx) {
|
||||
markErr := h.taskStore.MarkFailed(ctx, taskID, updater, prefix+": "+err.Error())
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
183
internal/task/export_shard.go
Normal file
183
internal/task/export_shard.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/exporter"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||
)
|
||||
|
||||
// ExportShardHandler 导出 shard 处理器。
|
||||
type ExportShardHandler struct {
|
||||
redis *redis.Client
|
||||
taskStore *postgres.ExportTaskStore
|
||||
shardStore *postgres.ExportShardTaskStore
|
||||
asynqClient *asynq.Client
|
||||
storageSvc *storage.Service
|
||||
sceneRegistry *exporter.Registry
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewExportShardHandler 创建导出 shard 处理器。
|
||||
func NewExportShardHandler(
|
||||
redis *redis.Client,
|
||||
taskStore *postgres.ExportTaskStore,
|
||||
shardStore *postgres.ExportShardTaskStore,
|
||||
asynqClient *asynq.Client,
|
||||
storageSvc *storage.Service,
|
||||
sceneRegistry *exporter.Registry,
|
||||
logger *zap.Logger,
|
||||
) *ExportShardHandler {
|
||||
return &ExportShardHandler{
|
||||
redis: redis,
|
||||
taskStore: taskStore,
|
||||
shardStore: shardStore,
|
||||
asynqClient: asynqClient,
|
||||
storageSvc: storageSvc,
|
||||
sceneRegistry: sceneRegistry,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleExportShard 处理导出 shard 任务。
|
||||
func (h *ExportShardHandler) HandleExportShard(ctx context.Context, task *asynq.Task) error {
|
||||
var payload ExportShardPayload
|
||||
if err := parseExportPayload(task.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析导出 shard 任务载荷失败", zap.Error(err))
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
lockKey := constants.RedisExportShardLockKey(payload.TaskID, payload.ShardID)
|
||||
locked, err := tryAcquireLock(ctx, h.redis, lockKey, exportShardLockTTL)
|
||||
if err != nil {
|
||||
h.logger.Error("获取导出 shard 锁失败", zap.Uint("task_id", payload.TaskID), zap.Uint("shard_id", payload.ShardID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if !locked {
|
||||
return nil
|
||||
}
|
||||
defer releaseLock(ctx, h.redis, lockKey)
|
||||
|
||||
exportTask, err := h.taskStore.GetByIDForWorker(ctx, payload.TaskID)
|
||||
if err != nil {
|
||||
h.logger.Error("查询导出任务失败", zap.Uint("task_id", payload.TaskID), zap.Error(err))
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
shardTask, err := h.shardStore.GetByIDForWorker(ctx, payload.ShardID)
|
||||
if err != nil {
|
||||
h.logger.Error("查询导出分片失败", zap.Uint("task_id", payload.TaskID), zap.Uint("shard_id", payload.ShardID), zap.Error(err))
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
if shardTask.Status == constants.ExportShardStatusSuccess || shardTask.Status == constants.ExportShardStatusFailed || shardTask.Status == constants.ExportShardStatusCancelled {
|
||||
return nil
|
||||
}
|
||||
|
||||
updater := dispatchUpdater(exportTask)
|
||||
if exportTask.CancelRequested {
|
||||
_, _ = h.shardStore.MarkCancelled(ctx, shardTask.ID, updater, "任务已取消")
|
||||
_ = h.enqueueFinalize(ctx, exportTask.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
if shardTask.Status == constants.ExportShardStatusPending {
|
||||
started, err := h.shardStore.TryMarkProcessingIfPending(ctx, shardTask.ID, updater)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !started {
|
||||
latest, err := h.shardStore.GetByIDForWorker(ctx, shardTask.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latest.Status != constants.ExportShardStatusProcessing {
|
||||
return nil
|
||||
}
|
||||
shardTask = latest
|
||||
}
|
||||
}
|
||||
|
||||
strategy, ok := h.sceneRegistry.Get(exportTask.Scene)
|
||||
if !ok {
|
||||
_ = h.markShardFinalFailed(ctx, exportTask.ID, shardTask.ID, updater, "导出场景未注册")
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
rows, err := strategy.QueryRows(ctx, exportTask, shardTask.CursorStart, shardTask.CursorEnd)
|
||||
if err != nil {
|
||||
return h.handleShardError(ctx, exportTask.ID, shardTask.ID, updater, "查询分片数据失败", err)
|
||||
}
|
||||
|
||||
localPath, fileSize, err := writeExportFile(exportTask.Format, strategy.Headers(), rows)
|
||||
if err != nil {
|
||||
return h.handleShardError(ctx, exportTask.ID, shardTask.ID, updater, "生成分片文件失败", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(localPath) }()
|
||||
|
||||
fileName := fmt.Sprintf("%s-shard-%04d.%s", exportTask.TaskNo, shardTask.ShardNo, exportTask.Format)
|
||||
fileKey, err := uploadExportFile(ctx, h.storageSvc, localPath, fileName)
|
||||
if err != nil {
|
||||
return h.handleShardError(ctx, exportTask.ID, shardTask.ID, updater, "上传分片文件失败", err)
|
||||
}
|
||||
|
||||
updated, err := h.shardStore.MarkSuccess(ctx, shardTask.ID, updater, len(rows), fileKey, fileSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated {
|
||||
if err := h.taskStore.ApplyShardSuccess(ctx, exportTask.ID, updater, len(rows)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.enqueueFinalize(ctx, exportTask.ID); err != nil {
|
||||
h.logger.Warn("触发 finalize 入队失败", zap.Uint("task_id", exportTask.ID), zap.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ExportShardHandler) handleShardError(ctx context.Context, taskID, shardID uint, updater uint, prefix string, err error) error {
|
||||
if isFinalRetry(ctx) {
|
||||
_ = h.markShardFinalFailed(ctx, taskID, shardID, updater, prefix+": "+err.Error())
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ExportShardHandler) markShardFinalFailed(ctx context.Context, taskID, shardID uint, updater uint, message string) error {
|
||||
updated, err := h.shardStore.MarkFailed(ctx, shardID, updater, message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated {
|
||||
if err := h.taskStore.ApplyShardFailed(ctx, taskID, updater, message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = h.enqueueFinalize(ctx, taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ExportShardHandler) enqueueFinalize(ctx context.Context, taskID uint) error {
|
||||
return enqueueTask(
|
||||
ctx,
|
||||
h.asynqClient,
|
||||
constants.TaskTypeExportFinalize,
|
||||
ExportFinalizePayload{TaskID: taskID},
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(constants.ExportFinalizeRetryMax),
|
||||
asynq.Timeout(constants.ExportFinalizeTaskTimeout),
|
||||
asynq.ProcessIn(2*time.Second),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user