usage_cleanup_repo.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "strings"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. dbusagecleanuptask "github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. )
  15. type usageCleanupRepository struct {
  16. client *dbent.Client
  17. sql sqlExecutor
  18. }
  19. func NewUsageCleanupRepository(client *dbent.Client, sqlDB *sql.DB) service.UsageCleanupRepository {
  20. return newUsageCleanupRepositoryWithSQL(client, sqlDB)
  21. }
  22. func newUsageCleanupRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *usageCleanupRepository {
  23. return &usageCleanupRepository{client: client, sql: sqlq}
  24. }
  25. func (r *usageCleanupRepository) CreateTask(ctx context.Context, task *service.UsageCleanupTask) error {
  26. if task == nil {
  27. return nil
  28. }
  29. if r.client != nil {
  30. return r.createTaskWithEnt(ctx, task)
  31. }
  32. return r.createTaskWithSQL(ctx, task)
  33. }
  34. func (r *usageCleanupRepository) ListTasks(ctx context.Context, params pagination.PaginationParams) ([]service.UsageCleanupTask, *pagination.PaginationResult, error) {
  35. if r.client != nil {
  36. return r.listTasksWithEnt(ctx, params)
  37. }
  38. var total int64
  39. if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM usage_cleanup_tasks", nil, &total); err != nil {
  40. return nil, nil, err
  41. }
  42. if total == 0 {
  43. return []service.UsageCleanupTask{}, paginationResultFromTotal(0, params), nil
  44. }
  45. query := `
  46. SELECT id, status, filters, created_by, deleted_rows, error_message,
  47. canceled_by, canceled_at,
  48. started_at, finished_at, created_at, updated_at
  49. FROM usage_cleanup_tasks
  50. ORDER BY created_at DESC, id DESC
  51. LIMIT $1 OFFSET $2
  52. `
  53. rows, err := r.sql.QueryContext(ctx, query, params.Limit(), params.Offset())
  54. if err != nil {
  55. return nil, nil, err
  56. }
  57. defer func() { _ = rows.Close() }()
  58. tasks := make([]service.UsageCleanupTask, 0)
  59. for rows.Next() {
  60. var task service.UsageCleanupTask
  61. var filtersJSON []byte
  62. var errMsg sql.NullString
  63. var canceledBy sql.NullInt64
  64. var canceledAt sql.NullTime
  65. var startedAt sql.NullTime
  66. var finishedAt sql.NullTime
  67. if err := rows.Scan(
  68. &task.ID,
  69. &task.Status,
  70. &filtersJSON,
  71. &task.CreatedBy,
  72. &task.DeletedRows,
  73. &errMsg,
  74. &canceledBy,
  75. &canceledAt,
  76. &startedAt,
  77. &finishedAt,
  78. &task.CreatedAt,
  79. &task.UpdatedAt,
  80. ); err != nil {
  81. return nil, nil, err
  82. }
  83. if err := json.Unmarshal(filtersJSON, &task.Filters); err != nil {
  84. return nil, nil, fmt.Errorf("parse cleanup filters: %w", err)
  85. }
  86. if errMsg.Valid {
  87. task.ErrorMsg = &errMsg.String
  88. }
  89. if canceledBy.Valid {
  90. v := canceledBy.Int64
  91. task.CanceledBy = &v
  92. }
  93. if canceledAt.Valid {
  94. task.CanceledAt = &canceledAt.Time
  95. }
  96. if startedAt.Valid {
  97. task.StartedAt = &startedAt.Time
  98. }
  99. if finishedAt.Valid {
  100. task.FinishedAt = &finishedAt.Time
  101. }
  102. tasks = append(tasks, task)
  103. }
  104. if err := rows.Err(); err != nil {
  105. return nil, nil, err
  106. }
  107. return tasks, paginationResultFromTotal(total, params), nil
  108. }
  109. func (r *usageCleanupRepository) ClaimNextPendingTask(ctx context.Context, staleRunningAfterSeconds int64) (*service.UsageCleanupTask, error) {
  110. if staleRunningAfterSeconds <= 0 {
  111. staleRunningAfterSeconds = 1800
  112. }
  113. query := `
  114. WITH next AS (
  115. SELECT id
  116. FROM usage_cleanup_tasks
  117. WHERE status = $1
  118. OR (
  119. status = $2
  120. AND started_at IS NOT NULL
  121. AND started_at < NOW() - ($3 * interval '1 second')
  122. )
  123. ORDER BY created_at ASC
  124. LIMIT 1
  125. FOR UPDATE SKIP LOCKED
  126. )
  127. UPDATE usage_cleanup_tasks AS tasks
  128. SET status = $4,
  129. started_at = NOW(),
  130. finished_at = NULL,
  131. error_message = NULL,
  132. updated_at = NOW()
  133. FROM next
  134. WHERE tasks.id = next.id
  135. RETURNING tasks.id, tasks.status, tasks.filters, tasks.created_by, tasks.deleted_rows, tasks.error_message,
  136. tasks.started_at, tasks.finished_at, tasks.created_at, tasks.updated_at
  137. `
  138. var task service.UsageCleanupTask
  139. var filtersJSON []byte
  140. var errMsg sql.NullString
  141. var startedAt sql.NullTime
  142. var finishedAt sql.NullTime
  143. if err := scanSingleRow(
  144. ctx,
  145. r.sql,
  146. query,
  147. []any{
  148. service.UsageCleanupStatusPending,
  149. service.UsageCleanupStatusRunning,
  150. staleRunningAfterSeconds,
  151. service.UsageCleanupStatusRunning,
  152. },
  153. &task.ID,
  154. &task.Status,
  155. &filtersJSON,
  156. &task.CreatedBy,
  157. &task.DeletedRows,
  158. &errMsg,
  159. &startedAt,
  160. &finishedAt,
  161. &task.CreatedAt,
  162. &task.UpdatedAt,
  163. ); err != nil {
  164. if errors.Is(err, sql.ErrNoRows) {
  165. return nil, nil
  166. }
  167. return nil, err
  168. }
  169. if err := json.Unmarshal(filtersJSON, &task.Filters); err != nil {
  170. return nil, fmt.Errorf("parse cleanup filters: %w", err)
  171. }
  172. if errMsg.Valid {
  173. task.ErrorMsg = &errMsg.String
  174. }
  175. if startedAt.Valid {
  176. task.StartedAt = &startedAt.Time
  177. }
  178. if finishedAt.Valid {
  179. task.FinishedAt = &finishedAt.Time
  180. }
  181. return &task, nil
  182. }
  183. func (r *usageCleanupRepository) GetTaskStatus(ctx context.Context, taskID int64) (string, error) {
  184. if r.client != nil {
  185. return r.getTaskStatusWithEnt(ctx, taskID)
  186. }
  187. var status string
  188. if err := scanSingleRow(ctx, r.sql, "SELECT status FROM usage_cleanup_tasks WHERE id = $1", []any{taskID}, &status); err != nil {
  189. return "", err
  190. }
  191. return status, nil
  192. }
  193. func (r *usageCleanupRepository) UpdateTaskProgress(ctx context.Context, taskID int64, deletedRows int64) error {
  194. if r.client != nil {
  195. return r.updateTaskProgressWithEnt(ctx, taskID, deletedRows)
  196. }
  197. query := `
  198. UPDATE usage_cleanup_tasks
  199. SET deleted_rows = $1,
  200. updated_at = NOW()
  201. WHERE id = $2
  202. `
  203. _, err := r.sql.ExecContext(ctx, query, deletedRows, taskID)
  204. return err
  205. }
  206. func (r *usageCleanupRepository) CancelTask(ctx context.Context, taskID int64, canceledBy int64) (bool, error) {
  207. if r.client != nil {
  208. return r.cancelTaskWithEnt(ctx, taskID, canceledBy)
  209. }
  210. query := `
  211. UPDATE usage_cleanup_tasks
  212. SET status = $1,
  213. canceled_by = $3,
  214. canceled_at = NOW(),
  215. finished_at = NOW(),
  216. error_message = NULL,
  217. updated_at = NOW()
  218. WHERE id = $2
  219. AND status IN ($4, $5)
  220. RETURNING id
  221. `
  222. var id int64
  223. err := scanSingleRow(ctx, r.sql, query, []any{
  224. service.UsageCleanupStatusCanceled,
  225. taskID,
  226. canceledBy,
  227. service.UsageCleanupStatusPending,
  228. service.UsageCleanupStatusRunning,
  229. }, &id)
  230. if errors.Is(err, sql.ErrNoRows) {
  231. return false, nil
  232. }
  233. if err != nil {
  234. return false, err
  235. }
  236. return true, nil
  237. }
  238. func (r *usageCleanupRepository) MarkTaskSucceeded(ctx context.Context, taskID int64, deletedRows int64) error {
  239. if r.client != nil {
  240. return r.markTaskSucceededWithEnt(ctx, taskID, deletedRows)
  241. }
  242. query := `
  243. UPDATE usage_cleanup_tasks
  244. SET status = $1,
  245. deleted_rows = $2,
  246. finished_at = NOW(),
  247. updated_at = NOW()
  248. WHERE id = $3
  249. `
  250. _, err := r.sql.ExecContext(ctx, query, service.UsageCleanupStatusSucceeded, deletedRows, taskID)
  251. return err
  252. }
  253. func (r *usageCleanupRepository) MarkTaskFailed(ctx context.Context, taskID int64, deletedRows int64, errorMsg string) error {
  254. if r.client != nil {
  255. return r.markTaskFailedWithEnt(ctx, taskID, deletedRows, errorMsg)
  256. }
  257. query := `
  258. UPDATE usage_cleanup_tasks
  259. SET status = $1,
  260. deleted_rows = $2,
  261. error_message = $3,
  262. finished_at = NOW(),
  263. updated_at = NOW()
  264. WHERE id = $4
  265. `
  266. _, err := r.sql.ExecContext(ctx, query, service.UsageCleanupStatusFailed, deletedRows, errorMsg, taskID)
  267. return err
  268. }
  269. func (r *usageCleanupRepository) DeleteUsageLogsBatch(ctx context.Context, filters service.UsageCleanupFilters, limit int) (int64, error) {
  270. if filters.StartTime.IsZero() || filters.EndTime.IsZero() {
  271. return 0, fmt.Errorf("cleanup filters missing time range")
  272. }
  273. whereClause, args := buildUsageCleanupWhere(filters)
  274. if whereClause == "" {
  275. return 0, fmt.Errorf("cleanup filters missing time range")
  276. }
  277. args = append(args, limit)
  278. if db, ok := r.sql.(*sql.DB); ok {
  279. return r.deleteUsageLogsBatchWithRollupInvalidation(ctx, db, whereClause, args)
  280. }
  281. query := fmt.Sprintf(`
  282. WITH target AS (
  283. SELECT id
  284. FROM usage_logs
  285. WHERE %s
  286. ORDER BY created_at ASC, id ASC
  287. LIMIT $%d
  288. )
  289. DELETE FROM usage_logs
  290. WHERE id IN (SELECT id FROM target)
  291. RETURNING created_at
  292. `, whereClause, len(args))
  293. rows, err := r.sql.QueryContext(ctx, query, args...)
  294. if err != nil {
  295. return 0, err
  296. }
  297. defer func() { _ = rows.Close() }()
  298. var deleted int64
  299. for rows.Next() {
  300. deleted++
  301. }
  302. if err := rows.Err(); err != nil {
  303. return 0, err
  304. }
  305. return deleted, nil
  306. }
  307. func (r *usageCleanupRepository) deleteUsageLogsBatchWithRollupInvalidation(ctx context.Context, db *sql.DB, whereClause string, args []any) (int64, error) {
  308. tx, err := db.BeginTx(ctx, nil)
  309. if err != nil {
  310. return 0, err
  311. }
  312. rollback := func(err error) (int64, error) {
  313. _ = tx.Rollback()
  314. return 0, err
  315. }
  316. if err := lockGroupUsageRollupState(ctx, tx); err != nil {
  317. return rollback(err)
  318. }
  319. query := fmt.Sprintf(`
  320. WITH target AS (
  321. SELECT id
  322. FROM usage_logs
  323. WHERE %s
  324. ORDER BY created_at ASC, id ASC
  325. LIMIT $%d
  326. )
  327. DELETE FROM usage_logs
  328. WHERE id IN (SELECT id FROM target)
  329. RETURNING created_at
  330. `, whereClause, len(args))
  331. rows, err := tx.QueryContext(ctx, query, args...)
  332. if err != nil {
  333. return rollback(err)
  334. }
  335. var deleted int64
  336. var earliestDeletedAt time.Time
  337. for rows.Next() {
  338. var deletedAt time.Time
  339. if err := rows.Scan(&deletedAt); err != nil {
  340. _ = rows.Close()
  341. return rollback(err)
  342. }
  343. deleted++
  344. if earliestDeletedAt.IsZero() || deletedAt.Before(earliestDeletedAt) {
  345. earliestDeletedAt = deletedAt
  346. }
  347. }
  348. if err := rows.Err(); err != nil {
  349. _ = rows.Close()
  350. return rollback(err)
  351. }
  352. if err := rows.Close(); err != nil {
  353. return rollback(err)
  354. }
  355. if deleted > 0 {
  356. if err := invalidateGroupUsageRollupsAt(ctx, tx, earliestDeletedAt); err != nil {
  357. return rollback(err)
  358. }
  359. }
  360. if err := tx.Commit(); err != nil {
  361. return 0, err
  362. }
  363. return deleted, nil
  364. }
  365. func buildUsageCleanupWhere(filters service.UsageCleanupFilters) (string, []any) {
  366. conditions := make([]string, 0, 8)
  367. args := make([]any, 0, 8)
  368. idx := 1
  369. if !filters.StartTime.IsZero() {
  370. conditions = append(conditions, fmt.Sprintf("created_at >= $%d", idx))
  371. args = append(args, filters.StartTime)
  372. idx++
  373. }
  374. if !filters.EndTime.IsZero() {
  375. conditions = append(conditions, fmt.Sprintf("created_at <= $%d", idx))
  376. args = append(args, filters.EndTime)
  377. idx++
  378. }
  379. if filters.UserID != nil {
  380. conditions = append(conditions, fmt.Sprintf("user_id = $%d", idx))
  381. args = append(args, *filters.UserID)
  382. idx++
  383. }
  384. if filters.APIKeyID != nil {
  385. conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", idx))
  386. args = append(args, *filters.APIKeyID)
  387. idx++
  388. }
  389. if filters.AccountID != nil {
  390. conditions = append(conditions, fmt.Sprintf("account_id = $%d", idx))
  391. args = append(args, *filters.AccountID)
  392. idx++
  393. }
  394. if filters.GroupID != nil {
  395. conditions = append(conditions, fmt.Sprintf("group_id = $%d", idx))
  396. args = append(args, *filters.GroupID)
  397. idx++
  398. }
  399. if filters.Model != nil {
  400. model := strings.TrimSpace(*filters.Model)
  401. if model != "" {
  402. conditions = append(conditions, fmt.Sprintf("model = $%d", idx))
  403. args = append(args, model)
  404. idx++
  405. }
  406. }
  407. if filters.RequestType != nil {
  408. condition, conditionArgs := buildRequestTypeFilterCondition(idx, *filters.RequestType)
  409. conditions = append(conditions, condition)
  410. args = append(args, conditionArgs...)
  411. idx += len(conditionArgs)
  412. } else if filters.Stream != nil {
  413. conditions = append(conditions, fmt.Sprintf("stream = $%d", idx))
  414. args = append(args, *filters.Stream)
  415. idx++
  416. }
  417. if filters.BillingType != nil {
  418. conditions = append(conditions, fmt.Sprintf("billing_type = $%d", idx))
  419. args = append(args, *filters.BillingType)
  420. }
  421. return strings.Join(conditions, " AND "), args
  422. }
  423. func (r *usageCleanupRepository) createTaskWithEnt(ctx context.Context, task *service.UsageCleanupTask) error {
  424. client := clientFromContext(ctx, r.client)
  425. filtersJSON, err := json.Marshal(task.Filters)
  426. if err != nil {
  427. return fmt.Errorf("marshal cleanup filters: %w", err)
  428. }
  429. created, err := client.UsageCleanupTask.
  430. Create().
  431. SetStatus(task.Status).
  432. SetFilters(json.RawMessage(filtersJSON)).
  433. SetCreatedBy(task.CreatedBy).
  434. SetDeletedRows(task.DeletedRows).
  435. Save(ctx)
  436. if err != nil {
  437. return err
  438. }
  439. task.ID = created.ID
  440. task.CreatedAt = created.CreatedAt
  441. task.UpdatedAt = created.UpdatedAt
  442. return nil
  443. }
  444. func (r *usageCleanupRepository) createTaskWithSQL(ctx context.Context, task *service.UsageCleanupTask) error {
  445. filtersJSON, err := json.Marshal(task.Filters)
  446. if err != nil {
  447. return fmt.Errorf("marshal cleanup filters: %w", err)
  448. }
  449. query := `
  450. INSERT INTO usage_cleanup_tasks (
  451. status,
  452. filters,
  453. created_by,
  454. deleted_rows
  455. ) VALUES ($1, $2, $3, $4)
  456. RETURNING id, created_at, updated_at
  457. `
  458. if err := scanSingleRow(ctx, r.sql, query, []any{task.Status, filtersJSON, task.CreatedBy, task.DeletedRows}, &task.ID, &task.CreatedAt, &task.UpdatedAt); err != nil {
  459. return err
  460. }
  461. return nil
  462. }
  463. func (r *usageCleanupRepository) listTasksWithEnt(ctx context.Context, params pagination.PaginationParams) ([]service.UsageCleanupTask, *pagination.PaginationResult, error) {
  464. client := clientFromContext(ctx, r.client)
  465. query := client.UsageCleanupTask.Query()
  466. total, err := query.Clone().Count(ctx)
  467. if err != nil {
  468. return nil, nil, err
  469. }
  470. if total == 0 {
  471. return []service.UsageCleanupTask{}, paginationResultFromTotal(0, params), nil
  472. }
  473. rows, err := query.
  474. Order(dbent.Desc(dbusagecleanuptask.FieldCreatedAt), dbent.Desc(dbusagecleanuptask.FieldID)).
  475. Offset(params.Offset()).
  476. Limit(params.Limit()).
  477. All(ctx)
  478. if err != nil {
  479. return nil, nil, err
  480. }
  481. tasks := make([]service.UsageCleanupTask, 0, len(rows))
  482. for _, row := range rows {
  483. task, err := usageCleanupTaskFromEnt(row)
  484. if err != nil {
  485. return nil, nil, err
  486. }
  487. tasks = append(tasks, task)
  488. }
  489. return tasks, paginationResultFromTotal(int64(total), params), nil
  490. }
  491. func (r *usageCleanupRepository) getTaskStatusWithEnt(ctx context.Context, taskID int64) (string, error) {
  492. client := clientFromContext(ctx, r.client)
  493. task, err := client.UsageCleanupTask.Query().
  494. Where(dbusagecleanuptask.IDEQ(taskID)).
  495. Only(ctx)
  496. if err != nil {
  497. if dbent.IsNotFound(err) {
  498. return "", sql.ErrNoRows
  499. }
  500. return "", err
  501. }
  502. return task.Status, nil
  503. }
  504. func (r *usageCleanupRepository) updateTaskProgressWithEnt(ctx context.Context, taskID int64, deletedRows int64) error {
  505. client := clientFromContext(ctx, r.client)
  506. now := time.Now()
  507. _, err := client.UsageCleanupTask.Update().
  508. Where(dbusagecleanuptask.IDEQ(taskID)).
  509. SetDeletedRows(deletedRows).
  510. SetUpdatedAt(now).
  511. Save(ctx)
  512. return err
  513. }
  514. func (r *usageCleanupRepository) cancelTaskWithEnt(ctx context.Context, taskID int64, canceledBy int64) (bool, error) {
  515. client := clientFromContext(ctx, r.client)
  516. now := time.Now()
  517. affected, err := client.UsageCleanupTask.Update().
  518. Where(
  519. dbusagecleanuptask.IDEQ(taskID),
  520. dbusagecleanuptask.StatusIn(service.UsageCleanupStatusPending, service.UsageCleanupStatusRunning),
  521. ).
  522. SetStatus(service.UsageCleanupStatusCanceled).
  523. SetCanceledBy(canceledBy).
  524. SetCanceledAt(now).
  525. SetFinishedAt(now).
  526. ClearErrorMessage().
  527. SetUpdatedAt(now).
  528. Save(ctx)
  529. if err != nil {
  530. return false, err
  531. }
  532. return affected > 0, nil
  533. }
  534. func (r *usageCleanupRepository) markTaskSucceededWithEnt(ctx context.Context, taskID int64, deletedRows int64) error {
  535. client := clientFromContext(ctx, r.client)
  536. now := time.Now()
  537. _, err := client.UsageCleanupTask.Update().
  538. Where(dbusagecleanuptask.IDEQ(taskID)).
  539. SetStatus(service.UsageCleanupStatusSucceeded).
  540. SetDeletedRows(deletedRows).
  541. SetFinishedAt(now).
  542. SetUpdatedAt(now).
  543. Save(ctx)
  544. return err
  545. }
  546. func (r *usageCleanupRepository) markTaskFailedWithEnt(ctx context.Context, taskID int64, deletedRows int64, errorMsg string) error {
  547. client := clientFromContext(ctx, r.client)
  548. now := time.Now()
  549. _, err := client.UsageCleanupTask.Update().
  550. Where(dbusagecleanuptask.IDEQ(taskID)).
  551. SetStatus(service.UsageCleanupStatusFailed).
  552. SetDeletedRows(deletedRows).
  553. SetErrorMessage(errorMsg).
  554. SetFinishedAt(now).
  555. SetUpdatedAt(now).
  556. Save(ctx)
  557. return err
  558. }
  559. func usageCleanupTaskFromEnt(row *dbent.UsageCleanupTask) (service.UsageCleanupTask, error) {
  560. task := service.UsageCleanupTask{
  561. ID: row.ID,
  562. Status: row.Status,
  563. CreatedBy: row.CreatedBy,
  564. DeletedRows: row.DeletedRows,
  565. CreatedAt: row.CreatedAt,
  566. UpdatedAt: row.UpdatedAt,
  567. }
  568. if len(row.Filters) > 0 {
  569. if err := json.Unmarshal(row.Filters, &task.Filters); err != nil {
  570. return service.UsageCleanupTask{}, fmt.Errorf("parse cleanup filters: %w", err)
  571. }
  572. }
  573. if row.ErrorMessage != nil {
  574. task.ErrorMsg = row.ErrorMessage
  575. }
  576. if row.CanceledBy != nil {
  577. task.CanceledBy = row.CanceledBy
  578. }
  579. if row.CanceledAt != nil {
  580. task.CanceledAt = row.CanceledAt
  581. }
  582. if row.StartedAt != nil {
  583. task.StartedAt = row.StartedAt
  584. }
  585. if row.FinishedAt != nil {
  586. task.FinishedAt = row.FinishedAt
  587. }
  588. return task, nil
  589. }