From 5e2d1e902b02ff8b06d073364c5b32e620991a69 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:39:03 +0300 Subject: [PATCH] fix(uploads): retry failed files and report full storage --- apps/web/e2e/uploads.spec.ts | 84 +++++++++++++++++++ apps/web/src/features/uploads/uploader.ts | 65 ++++++++++---- apps/web/src/pages/uploads/SuggestPage.tsx | 19 ++++- apps/web/src/pages/uploads/uploads.module.css | 5 ++ packages/i18n/locales/en/admin.json | 1 + packages/i18n/locales/en/errors.json | 1 + packages/i18n/locales/ru/admin.json | 1 + packages/i18n/locales/ru/errors.json | 1 + services/api/internal/uploads/tus.go | 5 ++ services/api/internal/uploads/uploads_test.go | 41 +++++++++ 10 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 apps/web/e2e/uploads.spec.ts diff --git a/apps/web/e2e/uploads.spec.ts b/apps/web/e2e/uploads.spec.ts new file mode 100644 index 0000000..4170386 --- /dev/null +++ b/apps/web/e2e/uploads.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test'; + +test('locks the comment and retries one failed upload while another is running', async ({ page }, info) => { + await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); + await page.goto('/login'); + await page.getByLabel('Логин', { exact: true }).fill('mobile-admin'); + await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password'); + await page.getByRole('button', { name: 'Войти', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible(); + await page.evaluate(() => { + const original = window.fetch; + window.fetch = async (input, init) => { + const req = new Request(input, init); + const path = new URL(req.url).pathname; + const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); + if (path === '/api/v1/uploads/quota') return json({ limit_bytes: 1e9, used_bytes: 0, per_user_limit_bytes: null, user_used_bytes: 0, max_file_bytes: 1e9 }); + if (path === '/api/v1/uploads/precheck') { + const body = await req.json(); + return json({ files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) }); + } + if (path === '/api/v1/uploads' && req.method === 'POST') { + const body = await req.json(); + document.documentElement.dataset.uploadComment = body.comment; + document.documentElement.dataset.uploadReservations = String(Number(document.documentElement.dataset.uploadReservations ?? 0) + 1); + return json({ batch_id: 'test-batch', files: body.files.map((f: { client_id: string; name: string }) => ({ client_id: f.client_id, upload_file_id: f.name, tus_url: `/api/v1/uploads/files/${f.name}` })) }, 201); + } + return original(input, init); + }; + history.pushState({}, '', '/uploads/new'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + let retry = false; + let failures = 0; + let resumedOffset: string | undefined; + let releaseSecond!: () => void; + const secondPending = new Promise((resolve) => { releaseSecond = resolve; }); + await page.route('**/api/v1/uploads/files/*', async (route) => { + const req = route.request(); + const first = req.url().endsWith('/first.flac'); + if (req.method() === 'HEAD') { + await route.fulfill({ status: 200, headers: { 'Upload-Length': '1024', 'Upload-Offset': first && failures > 0 ? '256' : '0', 'Tus-Resumable': '1.0.0' } }); + return; + } + if (first && !retry) { + failures++; + await route.fulfill({ status: 507, contentType: 'application/problem+json', body: JSON.stringify({ status: 507, code: 'uploads.storage_full', detail: 'upload storage is full' }) }); + return; + } + if (first) resumedOffset = req.headers()['upload-offset']; + else await secondPending; + await route.fulfill({ status: 204, headers: { 'Upload-Offset': '1024', 'Tus-Resumable': '1.0.0' } }); + }); + + const inputs = page.locator('input[type=file]'); + await inputs.first().setInputFiles(['first.flac', 'second.flac'].map((name) => ({ name, mimeType: 'audio/flac', buffer: Buffer.alloc(1024) }))); + const comment = page.getByRole('textbox', { name: 'Комментарий для администратора' }); + await comment.fill('Lossless album'); + await expect(comment).toBeEnabled(); + const send = page.getByRole('button', { name: /^Отправить/ }); + await expect(send).toBeEnabled(); + await send.click(); + await expect(comment).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Очистить список' })).toBeDisabled(); + await expect(page.getByText(/На сервере закончилось место для загрузок/)).toBeVisible(); + const retryButton = page.getByRole('button', { name: 'Повторить загрузку first.flac', exact: true }); + await expect(retryButton).toBeEnabled(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + await page.screenshot({ path: info.outputPath('upload-retry.png'), fullPage: true }); + expect(failures).toBe(1); + retry = true; + await retryButton.click(); + await expect.poll(() => resumedOffset).toBe('256'); + await expect(retryButton).toHaveCount(0); + await expect(comment).toBeDisabled(); + await expect(page.locator('html')).toHaveAttribute('data-upload-reservations', '1'); + await expect(page.locator('html')).toHaveAttribute('data-upload-comment', 'Lossless album'); + releaseSecond(); + await expect(page.getByText(/Готово: 2 файла/)).toBeVisible(); + await page.getByRole('button', { name: 'Загрузить ещё', exact: true }).click(); + await inputs.first().setInputFiles({ name: 'third.flac', mimeType: 'audio/flac', buffer: Buffer.alloc(1024) }); + await expect(comment).toBeEnabled(); +}); diff --git a/apps/web/src/features/uploads/uploader.ts b/apps/web/src/features/uploads/uploader.ts index 0d34704..fc1269d 100644 --- a/apps/web/src/features/uploads/uploader.ts +++ b/apps/web/src/features/uploads/uploader.ts @@ -68,6 +68,9 @@ export class Uploader { private hashQueue: string[] = []; private hashing = false; private uploads = new Map(); + private transferQueue: { id: string; url: string }[] | null = null; + private transferWorkers = 0; + private transferDone: (() => void) | null = null; private refreshing: Promise | null = null; constructor(private readonly session: AuthSession) { @@ -241,7 +244,7 @@ export class Uploader { } } this.persist(); - await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); + await this.transfer(queue); } catch (err) { this.set({ error: err }); } finally { @@ -249,24 +252,57 @@ export class Uploader { } } + get canRetryFailed(): boolean { + return !this.state.busy || this.transferQueue !== null; + } + /** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */ - async retryFailed() { - const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl); - if (this.state.busy || !failed.length) return; + async retryFailed(id?: string) { + const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl && it.file && (!id || it.id === id)); + if (!this.canRetryFailed || !failed.length) return; + const queue = failed.map((it) => ({ id: it.id, url: it.tusUrl! })); + for (const it of failed) this.patch(it.id, { stage: 'uploading', message: undefined, code: undefined }); + // При продолжающейся пачке ставим повтор в её очередь, сохраняя предел параллельности. + if (this.transferQueue) { + this.transferQueue.push(...queue); + this.pumpTransfers(); + return; + } this.set({ busy: true, error: null }); try { - const queue = failed.map((it) => { - this.patch(it.id, { stage: 'uploading', message: undefined }); - return { id: it.id, url: it.tusUrl! }; - }); - await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); + await this.transfer(queue); } finally { this.set({ busy: false }); } } - private async drain(queue: { id: string; url: string }[]) { - for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url); + private transfer(queue: { id: string; url: string }[]): Promise { + return new Promise((resolve) => { + this.transferQueue = queue; + this.transferDone = resolve; + this.pumpTransfers(); + }); + } + + private pumpTransfers() { + const queue = this.transferQueue; + if (!queue) return; + while (this.transferWorkers < PARALLEL && queue.length) { + const next = queue.shift()!; + this.transferWorkers++; + void this.upload(next.id, next.url).catch((err: unknown) => { + this.patch(next.id, { stage: 'error', message: err instanceof Error ? err.message : String(err) }); + }).finally(() => { + this.transferWorkers--; + this.pumpTransfers(); + }); + } + if (this.transferWorkers === 0) { + this.transferQueue = null; + const done = this.transferDone; + this.transferDone = null; + done?.(); + } } private upload(id: string, url: string): Promise { @@ -291,7 +327,7 @@ export class Uploader { return true; } // Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит). - return status === undefined || status === 0 || status >= 500 || status === 409 || status === 423; + return status === undefined || status === 0 || (status >= 500 && status !== 507) || status === 409 || status === 423; }, onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }), onSuccess: () => { @@ -302,7 +338,8 @@ export class Uploader { }, onError: (err) => { this.uploads.delete(id); - this.patch(id, { stage: 'error', message: err.message }); + const status = (err as tus.DetailedError).originalResponse?.getStatus(); + this.patch(id, { stage: 'error', message: err.message, code: status === 507 ? 'uploads.storage_full' : undefined }); resolve(); }, }); @@ -398,7 +435,7 @@ export class Uploader { if (!queue.length) return; this.set({ busy: true }); try { - await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); + await this.transfer(queue); } finally { this.set({ busy: false }); this.persist(); diff --git a/apps/web/src/pages/uploads/SuggestPage.tsx b/apps/web/src/pages/uploads/SuggestPage.tsx index af7d5ac..24024c0 100644 --- a/apps/web/src/pages/uploads/SuggestPage.tsx +++ b/apps/web/src/pages/uploads/SuggestPage.tsx @@ -129,7 +129,7 @@ export function SuggestPage() { {st.items.length > 0 && (
{st.items.map((it) => ( - uploader.remove(it.id)} /> + void uploader.retryFailed(it.id)} onRemove={() => uploader.remove(it.id)} /> ))}
)} @@ -141,6 +141,7 @@ export function SuggestPage() {