mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
fix(downloader): separate interrupted task recovery
This commit is contained in:
@@ -184,6 +184,7 @@
|
||||
"billing_paused",
|
||||
"pausing",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"uploading",
|
||||
"canceling",
|
||||
"completed",
|
||||
@@ -331,6 +332,7 @@
|
||||
"billing_paused",
|
||||
"pausing",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"uploading",
|
||||
"canceling",
|
||||
"completed",
|
||||
@@ -708,6 +710,7 @@
|
||||
"billing_paused",
|
||||
"pausing",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"uploading",
|
||||
"canceling",
|
||||
"completed",
|
||||
@@ -1008,6 +1011,7 @@
|
||||
"billing_paused",
|
||||
"pausing",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"uploading",
|
||||
"canceling",
|
||||
"completed",
|
||||
@@ -1194,6 +1198,7 @@
|
||||
"billing_paused",
|
||||
"pausing",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"uploading",
|
||||
"canceling",
|
||||
"completed",
|
||||
@@ -1471,6 +1476,7 @@
|
||||
"billing_paused",
|
||||
"pausing",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"uploading",
|
||||
"canceling",
|
||||
"completed",
|
||||
@@ -1728,6 +1734,7 @@
|
||||
"billing_paused",
|
||||
"pausing",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"uploading",
|
||||
"canceling",
|
||||
"completed",
|
||||
@@ -2119,6 +2126,7 @@
|
||||
"billing_paused",
|
||||
"pausing",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"uploading",
|
||||
"canceling",
|
||||
"completed",
|
||||
|
||||
@@ -196,6 +196,7 @@ func (c *Client) AssignedTasks(ctx context.Context) ([]DownloadTask, error) {
|
||||
return c.assignedTasks(ctx, []openapi.GetApiDownloadTasksParamsStatus{
|
||||
openapi.GetApiDownloadTasksParamsStatusAssigned,
|
||||
openapi.GetApiDownloadTasksParamsStatusRunning,
|
||||
openapi.GetApiDownloadTasksParamsStatusInterrupted,
|
||||
openapi.GetApiDownloadTasksParamsStatusUploading,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,12 +53,12 @@ func TestAssignedTasksFetchesRecoverableStatuses(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(statuses)
|
||||
expected := []string{"assigned", "running", "uploading"}
|
||||
expected := []string{"assigned", "interrupted", "running", "uploading"}
|
||||
if !reflect.DeepEqual(statuses, expected) {
|
||||
t.Fatalf("expected recoverable statuses %v, got %v", expected, statuses)
|
||||
}
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("expected three tasks, got %d", len(tasks))
|
||||
if len(tasks) != 4 {
|
||||
t.Fatalf("expected four tasks, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ const (
|
||||
GetApiDownloadTasksParamsStatusCanceling GetApiDownloadTasksParamsStatus = "canceling"
|
||||
GetApiDownloadTasksParamsStatusCompleted GetApiDownloadTasksParamsStatus = "completed"
|
||||
GetApiDownloadTasksParamsStatusFailed GetApiDownloadTasksParamsStatus = "failed"
|
||||
GetApiDownloadTasksParamsStatusInterrupted GetApiDownloadTasksParamsStatus = "interrupted"
|
||||
GetApiDownloadTasksParamsStatusPaused GetApiDownloadTasksParamsStatus = "paused"
|
||||
GetApiDownloadTasksParamsStatusPausing GetApiDownloadTasksParamsStatus = "pausing"
|
||||
GetApiDownloadTasksParamsStatusQueued GetApiDownloadTasksParamsStatus = "queued"
|
||||
@@ -193,6 +194,8 @@ func (e GetApiDownloadTasksParamsStatus) Valid() bool {
|
||||
return true
|
||||
case GetApiDownloadTasksParamsStatusFailed:
|
||||
return true
|
||||
case GetApiDownloadTasksParamsStatusInterrupted:
|
||||
return true
|
||||
case GetApiDownloadTasksParamsStatusPaused:
|
||||
return true
|
||||
case GetApiDownloadTasksParamsStatusPausing:
|
||||
@@ -354,6 +357,7 @@ const (
|
||||
GetApiDownloadTasks200JSONResponseBodyItemsStatusCanceling GetApiDownloadTasks200JSONResponseBodyItemsStatus = "canceling"
|
||||
GetApiDownloadTasks200JSONResponseBodyItemsStatusCompleted GetApiDownloadTasks200JSONResponseBodyItemsStatus = "completed"
|
||||
GetApiDownloadTasks200JSONResponseBodyItemsStatusFailed GetApiDownloadTasks200JSONResponseBodyItemsStatus = "failed"
|
||||
GetApiDownloadTasks200JSONResponseBodyItemsStatusInterrupted GetApiDownloadTasks200JSONResponseBodyItemsStatus = "interrupted"
|
||||
GetApiDownloadTasks200JSONResponseBodyItemsStatusPaused GetApiDownloadTasks200JSONResponseBodyItemsStatus = "paused"
|
||||
GetApiDownloadTasks200JSONResponseBodyItemsStatusPausing GetApiDownloadTasks200JSONResponseBodyItemsStatus = "pausing"
|
||||
GetApiDownloadTasks200JSONResponseBodyItemsStatusQueued GetApiDownloadTasks200JSONResponseBodyItemsStatus = "queued"
|
||||
@@ -376,6 +380,8 @@ func (e GetApiDownloadTasks200JSONResponseBodyItemsStatus) Valid() bool {
|
||||
return true
|
||||
case GetApiDownloadTasks200JSONResponseBodyItemsStatusFailed:
|
||||
return true
|
||||
case GetApiDownloadTasks200JSONResponseBodyItemsStatusInterrupted:
|
||||
return true
|
||||
case GetApiDownloadTasks200JSONResponseBodyItemsStatusPaused:
|
||||
return true
|
||||
case GetApiDownloadTasks200JSONResponseBodyItemsStatusPausing:
|
||||
@@ -492,6 +498,7 @@ const (
|
||||
PostApiDownloadTasks201JSONResponseBodyStatusCanceling PostApiDownloadTasks201JSONResponseBodyStatus = "canceling"
|
||||
PostApiDownloadTasks201JSONResponseBodyStatusCompleted PostApiDownloadTasks201JSONResponseBodyStatus = "completed"
|
||||
PostApiDownloadTasks201JSONResponseBodyStatusFailed PostApiDownloadTasks201JSONResponseBodyStatus = "failed"
|
||||
PostApiDownloadTasks201JSONResponseBodyStatusInterrupted PostApiDownloadTasks201JSONResponseBodyStatus = "interrupted"
|
||||
PostApiDownloadTasks201JSONResponseBodyStatusPaused PostApiDownloadTasks201JSONResponseBodyStatus = "paused"
|
||||
PostApiDownloadTasks201JSONResponseBodyStatusPausing PostApiDownloadTasks201JSONResponseBodyStatus = "pausing"
|
||||
PostApiDownloadTasks201JSONResponseBodyStatusQueued PostApiDownloadTasks201JSONResponseBodyStatus = "queued"
|
||||
@@ -514,6 +521,8 @@ func (e PostApiDownloadTasks201JSONResponseBodyStatus) Valid() bool {
|
||||
return true
|
||||
case PostApiDownloadTasks201JSONResponseBodyStatusFailed:
|
||||
return true
|
||||
case PostApiDownloadTasks201JSONResponseBodyStatusInterrupted:
|
||||
return true
|
||||
case PostApiDownloadTasks201JSONResponseBodyStatusPaused:
|
||||
return true
|
||||
case PostApiDownloadTasks201JSONResponseBodyStatusPausing:
|
||||
@@ -537,6 +546,7 @@ const (
|
||||
GetApiDownloadTasksEventsParamsStatusCanceling GetApiDownloadTasksEventsParamsStatus = "canceling"
|
||||
GetApiDownloadTasksEventsParamsStatusCompleted GetApiDownloadTasksEventsParamsStatus = "completed"
|
||||
GetApiDownloadTasksEventsParamsStatusFailed GetApiDownloadTasksEventsParamsStatus = "failed"
|
||||
GetApiDownloadTasksEventsParamsStatusInterrupted GetApiDownloadTasksEventsParamsStatus = "interrupted"
|
||||
GetApiDownloadTasksEventsParamsStatusPaused GetApiDownloadTasksEventsParamsStatus = "paused"
|
||||
GetApiDownloadTasksEventsParamsStatusPausing GetApiDownloadTasksEventsParamsStatus = "pausing"
|
||||
GetApiDownloadTasksEventsParamsStatusQueued GetApiDownloadTasksEventsParamsStatus = "queued"
|
||||
@@ -559,6 +569,8 @@ func (e GetApiDownloadTasksEventsParamsStatus) Valid() bool {
|
||||
return true
|
||||
case GetApiDownloadTasksEventsParamsStatusFailed:
|
||||
return true
|
||||
case GetApiDownloadTasksEventsParamsStatusInterrupted:
|
||||
return true
|
||||
case GetApiDownloadTasksEventsParamsStatusPaused:
|
||||
return true
|
||||
case GetApiDownloadTasksEventsParamsStatusPausing:
|
||||
@@ -720,6 +732,7 @@ const (
|
||||
GetApiDownloadTasksId200JSONResponseBodyStatusCanceling GetApiDownloadTasksId200JSONResponseBodyStatus = "canceling"
|
||||
GetApiDownloadTasksId200JSONResponseBodyStatusCompleted GetApiDownloadTasksId200JSONResponseBodyStatus = "completed"
|
||||
GetApiDownloadTasksId200JSONResponseBodyStatusFailed GetApiDownloadTasksId200JSONResponseBodyStatus = "failed"
|
||||
GetApiDownloadTasksId200JSONResponseBodyStatusInterrupted GetApiDownloadTasksId200JSONResponseBodyStatus = "interrupted"
|
||||
GetApiDownloadTasksId200JSONResponseBodyStatusPaused GetApiDownloadTasksId200JSONResponseBodyStatus = "paused"
|
||||
GetApiDownloadTasksId200JSONResponseBodyStatusPausing GetApiDownloadTasksId200JSONResponseBodyStatus = "pausing"
|
||||
GetApiDownloadTasksId200JSONResponseBodyStatusQueued GetApiDownloadTasksId200JSONResponseBodyStatus = "queued"
|
||||
@@ -742,6 +755,8 @@ func (e GetApiDownloadTasksId200JSONResponseBodyStatus) Valid() bool {
|
||||
return true
|
||||
case GetApiDownloadTasksId200JSONResponseBodyStatusFailed:
|
||||
return true
|
||||
case GetApiDownloadTasksId200JSONResponseBodyStatusInterrupted:
|
||||
return true
|
||||
case GetApiDownloadTasksId200JSONResponseBodyStatusPaused:
|
||||
return true
|
||||
case GetApiDownloadTasksId200JSONResponseBodyStatusPausing:
|
||||
@@ -816,6 +831,7 @@ const (
|
||||
PatchApiDownloadTasksIdJSONBodyStatusCanceling PatchApiDownloadTasksIdJSONBodyStatus = "canceling"
|
||||
PatchApiDownloadTasksIdJSONBodyStatusCompleted PatchApiDownloadTasksIdJSONBodyStatus = "completed"
|
||||
PatchApiDownloadTasksIdJSONBodyStatusFailed PatchApiDownloadTasksIdJSONBodyStatus = "failed"
|
||||
PatchApiDownloadTasksIdJSONBodyStatusInterrupted PatchApiDownloadTasksIdJSONBodyStatus = "interrupted"
|
||||
PatchApiDownloadTasksIdJSONBodyStatusPaused PatchApiDownloadTasksIdJSONBodyStatus = "paused"
|
||||
PatchApiDownloadTasksIdJSONBodyStatusPausing PatchApiDownloadTasksIdJSONBodyStatus = "pausing"
|
||||
PatchApiDownloadTasksIdJSONBodyStatusQueued PatchApiDownloadTasksIdJSONBodyStatus = "queued"
|
||||
@@ -838,6 +854,8 @@ func (e PatchApiDownloadTasksIdJSONBodyStatus) Valid() bool {
|
||||
return true
|
||||
case PatchApiDownloadTasksIdJSONBodyStatusFailed:
|
||||
return true
|
||||
case PatchApiDownloadTasksIdJSONBodyStatusInterrupted:
|
||||
return true
|
||||
case PatchApiDownloadTasksIdJSONBodyStatusPaused:
|
||||
return true
|
||||
case PatchApiDownloadTasksIdJSONBodyStatusPausing:
|
||||
@@ -933,6 +951,7 @@ const (
|
||||
PatchApiDownloadTasksId200JSONResponseBodyStatusCanceling PatchApiDownloadTasksId200JSONResponseBodyStatus = "canceling"
|
||||
PatchApiDownloadTasksId200JSONResponseBodyStatusCompleted PatchApiDownloadTasksId200JSONResponseBodyStatus = "completed"
|
||||
PatchApiDownloadTasksId200JSONResponseBodyStatusFailed PatchApiDownloadTasksId200JSONResponseBodyStatus = "failed"
|
||||
PatchApiDownloadTasksId200JSONResponseBodyStatusInterrupted PatchApiDownloadTasksId200JSONResponseBodyStatus = "interrupted"
|
||||
PatchApiDownloadTasksId200JSONResponseBodyStatusPaused PatchApiDownloadTasksId200JSONResponseBodyStatus = "paused"
|
||||
PatchApiDownloadTasksId200JSONResponseBodyStatusPausing PatchApiDownloadTasksId200JSONResponseBodyStatus = "pausing"
|
||||
PatchApiDownloadTasksId200JSONResponseBodyStatusQueued PatchApiDownloadTasksId200JSONResponseBodyStatus = "queued"
|
||||
@@ -955,6 +974,8 @@ func (e PatchApiDownloadTasksId200JSONResponseBodyStatus) Valid() bool {
|
||||
return true
|
||||
case PatchApiDownloadTasksId200JSONResponseBodyStatusFailed:
|
||||
return true
|
||||
case PatchApiDownloadTasksId200JSONResponseBodyStatusInterrupted:
|
||||
return true
|
||||
case PatchApiDownloadTasksId200JSONResponseBodyStatusPaused:
|
||||
return true
|
||||
case PatchApiDownloadTasksId200JSONResponseBodyStatusPausing:
|
||||
@@ -1077,6 +1098,7 @@ const (
|
||||
PostApiDownloadTasksIdActions200JSONResponseBody0StatusCanceling PostApiDownloadTasksIdActions200JSONResponseBody0Status = "canceling"
|
||||
PostApiDownloadTasksIdActions200JSONResponseBody0StatusCompleted PostApiDownloadTasksIdActions200JSONResponseBody0Status = "completed"
|
||||
PostApiDownloadTasksIdActions200JSONResponseBody0StatusFailed PostApiDownloadTasksIdActions200JSONResponseBody0Status = "failed"
|
||||
PostApiDownloadTasksIdActions200JSONResponseBody0StatusInterrupted PostApiDownloadTasksIdActions200JSONResponseBody0Status = "interrupted"
|
||||
PostApiDownloadTasksIdActions200JSONResponseBody0StatusPaused PostApiDownloadTasksIdActions200JSONResponseBody0Status = "paused"
|
||||
PostApiDownloadTasksIdActions200JSONResponseBody0StatusPausing PostApiDownloadTasksIdActions200JSONResponseBody0Status = "pausing"
|
||||
PostApiDownloadTasksIdActions200JSONResponseBody0StatusQueued PostApiDownloadTasksIdActions200JSONResponseBody0Status = "queued"
|
||||
@@ -1099,6 +1121,8 @@ func (e PostApiDownloadTasksIdActions200JSONResponseBody0Status) Valid() bool {
|
||||
return true
|
||||
case PostApiDownloadTasksIdActions200JSONResponseBody0StatusFailed:
|
||||
return true
|
||||
case PostApiDownloadTasksIdActions200JSONResponseBody0StatusInterrupted:
|
||||
return true
|
||||
case PostApiDownloadTasksIdActions200JSONResponseBody0StatusPaused:
|
||||
return true
|
||||
case PostApiDownloadTasksIdActions200JSONResponseBody0StatusPausing:
|
||||
|
||||
@@ -256,12 +256,12 @@ func (w *Worker) process(ctx context.Context, task client.DownloadTask) {
|
||||
}
|
||||
zero := int64(0)
|
||||
if _, updateErr := w.updateTask(context.WithoutCancel(ctx), task.ID, client.TaskPatch{
|
||||
Status: "paused",
|
||||
Status: "interrupted",
|
||||
DownloadBps: &zero,
|
||||
StorageUploadBps: &zero,
|
||||
Detail: shutdownPauseDetail(currentDetail),
|
||||
Detail: interruptedDetail(currentDetail),
|
||||
}); updateErr != nil {
|
||||
log.Error("failed to mark task paused after shutdown", "error", updateErr)
|
||||
log.Error("failed to mark task interrupted after shutdown", "error", updateErr)
|
||||
}
|
||||
log.Info("task stopped by context cancellation")
|
||||
return
|
||||
@@ -331,14 +331,14 @@ func (w *Worker) uploadAndComplete(
|
||||
uploadingDetail.Phase = "uploading"
|
||||
uploadingDetail.PeerUploadBps = nil
|
||||
if _, updateErr := w.updateTask(context.WithoutCancel(ctx), task.ID, client.TaskPatch{
|
||||
Status: "paused",
|
||||
Status: "interrupted",
|
||||
DownloadedBytes: &downloadedBytes,
|
||||
TotalBytes: &downloadedBytes,
|
||||
DownloadBps: &zero,
|
||||
StorageUploadBps: &zero,
|
||||
Detail: shutdownPauseDetail(uploadingDetail),
|
||||
Detail: interruptedDetail(uploadingDetail),
|
||||
}); updateErr != nil {
|
||||
log.Error("failed to mark task paused after upload shutdown", "error", updateErr)
|
||||
log.Error("failed to mark task interrupted after upload shutdown", "error", updateErr)
|
||||
}
|
||||
log.Info("task upload stopped by context cancellation")
|
||||
return
|
||||
@@ -407,11 +407,11 @@ func withDownloadETA(detail *client.DownloadTaskDetail, downloaded int64, total
|
||||
return detail
|
||||
}
|
||||
|
||||
func shutdownPauseDetail(detail *client.DownloadTaskDetail) *client.DownloadTaskDetail {
|
||||
func interruptedDetail(detail *client.DownloadTaskDetail) *client.DownloadTaskDetail {
|
||||
if detail == nil {
|
||||
detail = &client.DownloadTaskDetail{}
|
||||
}
|
||||
detail.Message = "Paused because the downloader stopped"
|
||||
detail.Message = "Interrupted because the downloader stopped"
|
||||
return detail
|
||||
}
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ func TestWorkerLifecycleRetriesUploadWithoutRedownloading(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadShutdownMarksTaskPaused(t *testing.T) {
|
||||
func TestDownloadShutdownMarksTaskInterrupted(t *testing.T) {
|
||||
api := &recordingAPI{}
|
||||
eng := &recordingEngine{downloadErr: context.Canceled}
|
||||
w := NewWithAPI(config.Config{}, api)
|
||||
@@ -264,16 +264,16 @@ func TestDownloadShutdownMarksTaskPaused(t *testing.T) {
|
||||
|
||||
w.process(context.Background(), client.DownloadTask{ID: "task-1", Status: "running"})
|
||||
|
||||
patch := lastPatchWithStatus(t, api.patches, "paused")
|
||||
patch := lastPatchWithStatus(t, api.patches, "interrupted")
|
||||
if patch.DownloadBps == nil || *patch.DownloadBps != 0 {
|
||||
t.Fatalf("expected download speed to be reset, got %#v", patch.DownloadBps)
|
||||
}
|
||||
if patch.Detail == nil || patch.Detail.Message == "" {
|
||||
t.Fatalf("expected shutdown pause message, got %#v", patch.Detail)
|
||||
t.Fatalf("expected interrupted detail message, got %#v", patch.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadShutdownMarksTaskPaused(t *testing.T) {
|
||||
func TestUploadShutdownMarksTaskInterrupted(t *testing.T) {
|
||||
payloadPath := writeTempFile(t, "downloaded payload")
|
||||
api := &recordingAPI{
|
||||
createObjectDraft: client.ObjectDraft{ID: "object-1", Name: "payload.bin", UploadURL: "http://127.0.0.1:1"},
|
||||
@@ -290,12 +290,12 @@ func TestUploadShutdownMarksTaskPaused(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
|
||||
patch := lastPatchWithStatus(t, api.patches, "paused")
|
||||
patch := lastPatchWithStatus(t, api.patches, "interrupted")
|
||||
if patch.DownloadedBytes == nil || *patch.DownloadedBytes != int64(len("downloaded payload")) {
|
||||
t.Fatalf("expected upload shutdown to preserve downloaded checkpoint, got %#v", patch.DownloadedBytes)
|
||||
}
|
||||
if patch.Detail == nil || patch.Detail.Phase != "uploading" || patch.Detail.Message == "" {
|
||||
t.Fatalf("expected upload shutdown to preserve phase and add message, got %#v", patch.Detail)
|
||||
t.Fatalf("expected upload shutdown to preserve phase and add interrupted message, got %#v", patch.Detail)
|
||||
}
|
||||
if _, ok := findPatchWithStatus(api.patches, "failed"); ok {
|
||||
t.Fatalf("expected upload shutdown not to mark failed, got %#v", api.patches)
|
||||
|
||||
@@ -619,6 +619,96 @@ describe('Download tasks API integration', () => {
|
||||
await expect(deleteRes.json()).resolves.toEqual({ id: createdTask.id, deleted: true })
|
||||
})
|
||||
|
||||
it('lets the assigned downloader recover interrupted tasks without resuming user-paused tasks', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
|
||||
const createdDownloader = await registerDownloaderThroughDeviceLogin(app, 'interrupted-downloader')
|
||||
const downloaderHeaders = {
|
||||
Authorization: `Bearer ${createdDownloader.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const heartbeatRes = await app.request('/api/downloader/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({ ...heartbeat, currentTasks: 0 }),
|
||||
})
|
||||
expect(heartbeatRes.status).toBe(200)
|
||||
|
||||
const user = await authedHeaders(app, 'download-interrupted-user@example.com')
|
||||
const createTaskRes = await app.request('/api/download-tasks', {
|
||||
method: 'POST',
|
||||
headers: { ...user, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: { type: 'http', uri: 'https://example.com/interrupted.bin' },
|
||||
targetFolder: '',
|
||||
}),
|
||||
})
|
||||
expect(createTaskRes.status).toBe(201)
|
||||
const createdTask = (await createTaskRes.json()) as { id: string; status: string; assignedDownloaderId: string }
|
||||
expect(createdTask.status).toBe('assigned')
|
||||
expect(createdTask.assignedDownloaderId).toBe(createdDownloader.downloader.id)
|
||||
|
||||
const interruptedRes = await app.request(`/api/download-tasks/${createdTask.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({
|
||||
status: 'interrupted',
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 4096,
|
||||
detail: { phase: 'downloading', message: 'Interrupted because the downloader stopped' },
|
||||
}),
|
||||
})
|
||||
expect(interruptedRes.status).toBe(200)
|
||||
await expect(interruptedRes.json()).resolves.toMatchObject({
|
||||
status: 'interrupted',
|
||||
downloadedBytes: 1024,
|
||||
totalBytes: 4096,
|
||||
})
|
||||
|
||||
const interruptedAssignedRes = await app.request('/api/download-tasks?assignedTo=me&status=interrupted', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(interruptedAssignedRes.status).toBe(200)
|
||||
const interruptedAssigned = (await interruptedAssignedRes.json()) as {
|
||||
items: Array<{ id: string; status: string; uploadToken?: string }>
|
||||
}
|
||||
const interruptedTask = interruptedAssigned.items.find((item) => item.id === createdTask.id)
|
||||
expect(interruptedTask).toMatchObject({ status: 'interrupted' })
|
||||
expect(interruptedTask?.uploadToken).toBeTruthy()
|
||||
|
||||
const resumedProgressRes = await app.request(`/api/download-tasks/${createdTask.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({ status: 'running', downloadedBytes: 2048, downloadBps: 256 }),
|
||||
})
|
||||
expect(resumedProgressRes.status).toBe(200)
|
||||
await expect(resumedProgressRes.json()).resolves.toMatchObject({ status: 'running', downloadedBytes: 2048 })
|
||||
|
||||
const pauseRes = await app.request(`/api/download-tasks/${createdTask.id}/actions`, {
|
||||
method: 'POST',
|
||||
headers: { ...user, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'pause' }),
|
||||
})
|
||||
expect(pauseRes.status).toBe(200)
|
||||
await expect(pauseRes.json()).resolves.toMatchObject({ status: 'pausing' })
|
||||
|
||||
const pausedRes = await app.request(`/api/download-tasks/${createdTask.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({ status: 'paused' }),
|
||||
})
|
||||
expect(pausedRes.status).toBe(200)
|
||||
|
||||
const pausedProgressRes = await app.request(`/api/download-tasks/${createdTask.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({ status: 'running', downloadedBytes: 3072 }),
|
||||
})
|
||||
expect(pausedProgressRes.status).toBe(409)
|
||||
await expect(pausedProgressRes.json()).resolves.toEqual({ error: 'Task is paused' })
|
||||
})
|
||||
|
||||
it('preserves the completed download checkpoint when retrying an upload failure', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
|
||||
@@ -25,11 +25,12 @@ const CANCELABLE_TASK_STATUSES = [
|
||||
'running',
|
||||
'billing_paused',
|
||||
'paused',
|
||||
'interrupted',
|
||||
'uploading',
|
||||
'pausing',
|
||||
] as const
|
||||
const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'canceled'] as const
|
||||
const DOWNLOADER_TOKEN_TASK_STATUSES = ['assigned', 'running', 'uploading', 'billing_paused'] as const
|
||||
const DOWNLOADER_TOKEN_TASK_STATUSES = ['assigned', 'running', 'uploading', 'interrupted', 'billing_paused'] as const
|
||||
|
||||
export async function createDownloader(
|
||||
platform: Platform,
|
||||
@@ -142,6 +143,7 @@ export async function deleteDownloader(platform: Platform, id: string): Promise<
|
||||
'billing_paused',
|
||||
'pausing',
|
||||
'paused',
|
||||
'interrupted',
|
||||
'uploading',
|
||||
'canceling',
|
||||
]),
|
||||
@@ -464,7 +466,9 @@ export async function performDownloadTaskAction(
|
||||
}
|
||||
|
||||
if (action === 'resume') {
|
||||
if (task.status !== 'paused') throw new DownloadError('invalid_state', 'Only paused tasks can be resumed')
|
||||
if (!['paused', 'interrupted'].includes(task.status)) {
|
||||
throw new DownloadError('invalid_state', 'Only paused or interrupted tasks can be resumed')
|
||||
}
|
||||
await platform.db
|
||||
.update(downloadTasks)
|
||||
.set({
|
||||
@@ -486,10 +490,10 @@ export async function performDownloadTaskAction(
|
||||
if (action === 'cancel') {
|
||||
if (task.status === 'canceled') return toDownloadTask(task)
|
||||
if (!CANCELABLE_TASK_STATUSES.includes(task.status as (typeof CANCELABLE_TASK_STATUSES)[number])) {
|
||||
throw new DownloadError('invalid_state', 'Only active or paused tasks can be canceled')
|
||||
throw new DownloadError('invalid_state', 'Only active, interrupted, or paused tasks can be canceled')
|
||||
}
|
||||
const status =
|
||||
task.assignedDownloaderId && ['assigned', 'running', 'uploading', 'pausing'].includes(task.status)
|
||||
task.assignedDownloaderId && ['assigned', 'running', 'uploading', 'pausing', 'interrupted'].includes(task.status)
|
||||
? 'canceling'
|
||||
: 'canceled'
|
||||
await platform.db
|
||||
|
||||
@@ -9,6 +9,7 @@ export const downloadTaskStatusSchema = z.enum([
|
||||
'billing_paused',
|
||||
'pausing',
|
||||
'paused',
|
||||
'interrupted',
|
||||
'uploading',
|
||||
'canceling',
|
||||
'completed',
|
||||
|
||||
@@ -238,6 +238,7 @@ export type DownloadTaskStatus =
|
||||
| 'billing_paused'
|
||||
| 'pausing'
|
||||
| 'paused'
|
||||
| 'interrupted'
|
||||
| 'uploading'
|
||||
| 'canceling'
|
||||
| 'completed'
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"downloads.status.billing_paused": "Billing paused",
|
||||
"downloads.status.pausing": "Pausing",
|
||||
"downloads.status.paused": "Paused",
|
||||
"downloads.status.interrupted": "Interrupted",
|
||||
"downloads.status.uploading": "Uploading",
|
||||
"downloads.status.canceling": "Canceling",
|
||||
"downloads.status.completed": "Completed",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"downloads.status.billing_paused": "计费暂停",
|
||||
"downloads.status.pausing": "暂停中",
|
||||
"downloads.status.paused": "已暂停",
|
||||
"downloads.status.interrupted": "已中断",
|
||||
"downloads.status.uploading": "上传中",
|
||||
"downloads.status.canceling": "取消中",
|
||||
"downloads.status.completed": "已完成",
|
||||
|
||||
@@ -103,6 +103,7 @@ const STATUS_FILTERS: Array<{ value: DownloadTaskStatus | 'all'; labelKey: strin
|
||||
{ value: 'running', labelKey: 'downloads.status.running' },
|
||||
{ value: 'uploading', labelKey: 'downloads.status.uploading' },
|
||||
{ value: 'paused', labelKey: 'downloads.status.paused' },
|
||||
{ value: 'interrupted', labelKey: 'downloads.status.interrupted' },
|
||||
{ value: 'completed', labelKey: 'downloads.status.completed' },
|
||||
{ value: 'failed', labelKey: 'downloads.status.failed' },
|
||||
{ value: 'canceled', labelKey: 'downloads.status.canceled' },
|
||||
@@ -1269,7 +1270,7 @@ function TaskMenuItem({
|
||||
|
||||
function taskActions(task: DownloadTask): DownloadTaskAction[] {
|
||||
if (PAUSABLE_STATUSES.has(task.status)) return ['pause', 'cancel']
|
||||
if (task.status === 'paused') return ['resume', 'cancel']
|
||||
if (task.status === 'paused' || task.status === 'interrupted') return ['resume', 'cancel']
|
||||
if (task.status === 'billing_paused' || task.status === 'uploading' || task.status === 'pausing') return ['cancel']
|
||||
if (task.status === 'failed' || task.status === 'canceled') return ['retry', 'delete']
|
||||
if (task.status === 'completed') return ['delete']
|
||||
@@ -1283,7 +1284,7 @@ function availableBulkActions(tasks: DownloadTask[]): DownloadTaskAction[] {
|
||||
|
||||
function primaryTaskAction(task: DownloadTask): DownloadTaskAction | null {
|
||||
if (PAUSABLE_STATUSES.has(task.status)) return 'pause'
|
||||
if (task.status === 'paused') return 'resume'
|
||||
if (task.status === 'paused' || task.status === 'interrupted') return 'resume'
|
||||
if (task.status === 'failed' || task.status === 'canceled') return 'retry'
|
||||
return null
|
||||
}
|
||||
@@ -1720,6 +1721,11 @@ function StatusBadge({ status }: { status: DownloadTaskDisplayStatus }) {
|
||||
'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300',
|
||||
icon: <PauseCircle />,
|
||||
},
|
||||
interrupted: {
|
||||
className:
|
||||
'border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950/40 dark:text-yellow-300',
|
||||
icon: <AlertCircle />,
|
||||
},
|
||||
uploading: {
|
||||
className: 'border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-800 dark:bg-teal-950/40 dark:text-teal-300',
|
||||
icon: <Upload />,
|
||||
|
||||
Reference in New Issue
Block a user