Address multipart upload review feedback
This commit is contained in:
@@ -3,6 +3,7 @@ import {nanoid} from 'nanoid/non-secure'
|
|||||||
|
|
||||||
import {AbortError} from '#/lib/async/cancelable'
|
import {AbortError} from '#/lib/async/cancelable'
|
||||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||||
|
import {shouldRetryError} from '#/lib/strings/errors'
|
||||||
import {getServiceAuthToken} from '../upload.shared'
|
import {getServiceAuthToken} from '../upload.shared'
|
||||||
import {mimeToExt} from '../util'
|
import {mimeToExt} from '../util'
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +36,7 @@ export async function uploadVideoMultipart({
|
|||||||
onStarted?: () => void
|
onStarted?: () => void
|
||||||
}): Promise<AppBskyVideoDefs.JobStatus> {
|
}): Promise<AppBskyVideoDefs.JobStatus> {
|
||||||
throwIfAborted(signal)
|
throwIfAborted(signal)
|
||||||
const tokenProvider = createTokenProvider(agent)
|
const tokenProvider = createTokenProvider(agent, signal)
|
||||||
const token = await tokenProvider.get()
|
const token = await tokenProvider.get()
|
||||||
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
|
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
|
||||||
let session
|
let session
|
||||||
@@ -60,6 +61,7 @@ export async function uploadVideoMultipart({
|
|||||||
}
|
}
|
||||||
signal.addEventListener('abort', abortOnCancel, {once: true})
|
signal.addEventListener('abort', abortOnCancel, {once: true})
|
||||||
let reader: ReturnType<typeof createChunkReader> | undefined
|
let reader: ReturnType<typeof createChunkReader> | undefined
|
||||||
|
// Kept outside the upload try block for finish-time missing-part recovery.
|
||||||
let parts: ReturnType<typeof planParts> = []
|
let parts: ReturnType<typeof planParts> = []
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
@@ -85,9 +87,6 @@ export async function uploadVideoMultipart({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finish stores this credential for the later PDS blob upload, so use a
|
|
||||||
// fresh token rather than the one that may have aged during transfer.
|
|
||||||
await tokenProvider.get(true)
|
|
||||||
const activeReader = reader
|
const activeReader = reader
|
||||||
if (!activeReader) throw new Error('Video chunk reader is unavailable')
|
if (!activeReader) throw new Error('Video chunk reader is unavailable')
|
||||||
return await finishAndRecover({
|
return await finishAndRecover({
|
||||||
@@ -126,14 +125,18 @@ async function finishAndRecover({
|
|||||||
resendMissingParts,
|
resendMissingParts,
|
||||||
}: {
|
}: {
|
||||||
jobId: string
|
jobId: string
|
||||||
getToken: () => Promise<string>
|
getToken: (forceRefresh?: boolean) => Promise<string>
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
resendMissingParts: (receivedPartNumbers: number[]) => Promise<boolean>
|
resendMissingParts: (receivedPartNumbers: number[]) => Promise<boolean>
|
||||||
}): Promise<AppBskyVideoDefs.JobStatus> {
|
}): Promise<AppBskyVideoDefs.JobStatus> {
|
||||||
let createdFailures = 0
|
let createdFailures = 0
|
||||||
|
let forceTokenRefresh = true
|
||||||
while (true) {
|
while (true) {
|
||||||
throwIfAborted(signal)
|
throwIfAborted(signal)
|
||||||
const token = await getToken()
|
// Finish stores this credential for the later PDS blob upload. Refresh it
|
||||||
|
// once after part transfer, then reuse it while polling/recovering.
|
||||||
|
const token = await getToken(forceTokenRefresh)
|
||||||
|
forceTokenRefresh = false
|
||||||
try {
|
try {
|
||||||
const result = await finishUpload(jobId, token, signal)
|
const result = await finishUpload(jobId, token, signal)
|
||||||
return result.jobStatus
|
return result.jobStatus
|
||||||
@@ -177,6 +180,11 @@ async function finishAndRecover({
|
|||||||
`Multipart upload ${status.state}`,
|
`Multipart upload ${status.state}`,
|
||||||
status.state === 'aborted' ? 'UploadAborted' : 'UploadExpired',
|
status.state === 'aborted' ? 'UploadAborted' : 'UploadExpired',
|
||||||
)
|
)
|
||||||
|
case 'completed':
|
||||||
|
throw new MultipartUploadError(
|
||||||
|
'Multipart upload completed without a job status',
|
||||||
|
'InvalidUploadStatus',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,7 +241,7 @@ async function abortThenFallbackOrResolve(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTokenProvider(agent: AtpAgent) {
|
function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
|
||||||
let token: string | undefined
|
let token: string | undefined
|
||||||
let expiresAt = 0
|
let expiresAt = 0
|
||||||
let refresh: Promise<string> | undefined
|
let refresh: Promise<string> | undefined
|
||||||
@@ -242,11 +250,7 @@ function createTokenProvider(agent: AtpAgent) {
|
|||||||
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
|
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
|
||||||
if (!refresh) {
|
if (!refresh) {
|
||||||
const exp = Math.floor(Date.now() / 1000) + 60 * 30
|
const exp = Math.floor(Date.now() / 1000) + 60 * 30
|
||||||
refresh = getServiceAuthToken({
|
refresh = getServiceAuthTokenWithRetry(agent, exp, signal)
|
||||||
agent,
|
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
|
||||||
exp,
|
|
||||||
})
|
|
||||||
.then(nextToken => {
|
.then(nextToken => {
|
||||||
token = nextToken
|
token = nextToken
|
||||||
expiresAt = exp * 1000
|
expiresAt = exp * 1000
|
||||||
@@ -262,6 +266,30 @@ function createTokenProvider(agent: AtpAgent) {
|
|||||||
return {get}
|
return {get}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getServiceAuthTokenWithRetry(
|
||||||
|
agent: AtpAgent,
|
||||||
|
exp: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) {
|
||||||
|
let lastError: unknown
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
throwIfAborted(signal)
|
||||||
|
try {
|
||||||
|
return await getServiceAuthToken({
|
||||||
|
agent,
|
||||||
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
|
exp,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
throwIfAborted(signal)
|
||||||
|
if (!(err instanceof TypeError) && !shouldRetryError(err)) throw err
|
||||||
|
lastError = err
|
||||||
|
if (attempt < 3) await delay(500 * 2 ** (attempt - 1), signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
function throwIfAborted(signal: AbortSignal) {
|
function throwIfAborted(signal: AbortSignal) {
|
||||||
if (signal.aborted) throw new AbortError()
|
if (signal.aborted) throw new AbortError()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {AbortError} from '#/lib/async/cancelable'
|
||||||
import {type ChunkReader, type UploadPartFn} from './types'
|
import {type ChunkReader, type UploadPartFn} from './types'
|
||||||
import {uploadParts} from './uploadParts'
|
import {uploadParts} from './uploadParts'
|
||||||
|
|
||||||
@@ -103,6 +104,32 @@ describe('uploadParts', () => {
|
|||||||
).rejects.toThrow('always fails')
|
).rejects.toThrow('always fails')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves the originating error when sibling workers abort', async () => {
|
||||||
|
const uploadPart: UploadPartFn = ({part, signal}) => {
|
||||||
|
if (part.partNumber === 1) {
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(new AbortError()), {
|
||||||
|
once: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('part upload failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadParts({
|
||||||
|
parts: parts.slice(0, 2),
|
||||||
|
reader: fakeReader(),
|
||||||
|
uploadPart,
|
||||||
|
totalBytes: 20,
|
||||||
|
setProgress: () => {},
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
concurrency: 2,
|
||||||
|
maxAttempts: 1,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('part upload failed')
|
||||||
|
})
|
||||||
|
|
||||||
it('reports progress that reaches 1 when all parts complete', async () => {
|
it('reports progress that reaches 1 when all parts complete', async () => {
|
||||||
const progress: number[] = []
|
const progress: number[] = []
|
||||||
const uploadPart: UploadPartFn = ({part, chunk, onProgress}) => {
|
const uploadPart: UploadPartFn = ({part, chunk, onProgress}) => {
|
||||||
|
|||||||
@@ -78,10 +78,15 @@ export async function uploadParts({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
signal.removeEventListener('abort', abortWorkers)
|
signal.removeEventListener('abort', abortWorkers)
|
||||||
const failure = settled.find(
|
const failures = settled.filter(
|
||||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||||
)
|
)
|
||||||
if (signal.aborted) throw new AbortError()
|
if (signal.aborted) throw new AbortError()
|
||||||
|
// A sibling worker aborted after the first failure can settle earlier in
|
||||||
|
// array order. Preserve the originating error for fallback and telemetry.
|
||||||
|
const failure =
|
||||||
|
failures.find(result => !(result.reason instanceof AbortError)) ??
|
||||||
|
failures[0]
|
||||||
if (failure) throw failure.reason
|
if (failure) throw failure.reason
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user