fix(uploads): retry failed files and report full storage
ci / db / db (push) Successful in 1m51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m40s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 33s
ci / ts / ts (packages + web) (push) Failing after 5m16s

This commit is contained in:
mrleo1nid committed 2026-10-29 00:39:03 +03:00
1 parent b39e4dc28f
commit 5e2d1e902b
10 files changed
+205 -18

No files matched your search

+84
View File
@@ -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<void>((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();
});
+51 -14
View File
@@ -68,6 +68,9 @@ export class Uploader {
private hashQueue: string[] = [];
private hashing = false;
private uploads = new Map<string, tus.Upload>();
private transferQueue: { id: string; url: string }[] | null = null;
private transferWorkers = 0;
private transferDone: (() => void) | null = null;
private refreshing: Promise<unknown> | 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<void> {
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<void> {
@@ -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();
+15 -4
View File
@@ -129,7 +129,7 @@ export function SuggestPage() {
{st.items.length > 0 && (
<div className={u.files} aria-label={t('uploads.files')}>
{st.items.map((it) => (
<FileRow key={it.id} item={it} locked={st.busy || finished} onRemove={() => uploader.remove(it.id)} />
<FileRow key={it.id} item={it} locked={st.busy || finished} retryDisabled={!uploader.canRetryFailed} onRetry={() => void uploader.retryFailed(it.id)} onRemove={() => uploader.remove(it.id)} />
))}
</div>
)}
@@ -141,6 +141,7 @@ export function SuggestPage() {
<textarea
className={u.textarea}
value={comment}
disabled={st.busy}
maxLength={1000}
placeholder={t('uploads.comment_hint')}
onChange={(e) => setComment(e.target.value)}
@@ -188,7 +189,13 @@ export function SuggestPage() {
);
}
function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean; onRemove: () => void }) {
function FileRow({ item, locked, retryDisabled, onRetry, onRemove }: {
item: UploadItem;
locked: boolean;
retryDisabled: boolean;
onRetry: () => void;
onRemove: () => void;
}) {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
const showProgress = item.stage === 'hashing' || item.stage === 'uploading';
@@ -204,7 +211,7 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
note = { text: tr(`errors:${item.code ?? 'uploads.duplicate'}`), cls: u.bad };
break;
case 'error':
note = { text: t('uploads.file_error', { message: item.message ?? '' }), cls: u.bad };
note = { text: item.code === 'uploads.storage_full' ? tr('errors:uploads.storage_full') : t('uploads.file_error', { message: item.message ?? '' }), cls: u.bad };
break;
case 'ready':
if (item.possibleDuplicate) note = { text: t('uploads.possible_dup'), cls: u.warn };
@@ -229,7 +236,11 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
{note && <span className={note.cls}>{note.text}</span>}
</div>
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
{locked ? (
{item.stage === 'error' && item.tusUrl && item.file ? (
<Button variant="secondary" disabled={retryDisabled} aria-label={t('uploads.retry_file', { name: item.relPath })} onClick={onRetry}>
{t('uploads.retry')}
</Button>
) : locked ? (
<span />
) : (
<Button variant="ghost" aria-label={t('uploads.remove', { name: item.relPath })} onClick={onRemove}>
@@ -293,6 +293,11 @@
resize: vertical;
}
.textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 720px) {
.queueRow {
grid-template-columns: auto minmax(0, 1fr);