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
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:
1 parent
b39e4dc28f
commit
5e2d1e902b
10 files changed
+205
-18
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user