scaffold multipart video upload primitives
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
import {createProgressAggregator} from './aggregateProgress'
|
||||||
|
|
||||||
|
describe('createProgressAggregator', () => {
|
||||||
|
it('sums bytes across parts against the total', () => {
|
||||||
|
const progress: number[] = []
|
||||||
|
const report = createProgressAggregator(100, p => progress.push(p))
|
||||||
|
|
||||||
|
report(1, 50)
|
||||||
|
report(2, 25)
|
||||||
|
expect(progress).toEqual([0.5, 0.75])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites a part running count rather than adding it', () => {
|
||||||
|
const progress: number[] = []
|
||||||
|
const report = createProgressAggregator(100, p => progress.push(p))
|
||||||
|
|
||||||
|
report(1, 20)
|
||||||
|
report(1, 40)
|
||||||
|
expect(progress).toEqual([0.2, 0.4])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps to 1', () => {
|
||||||
|
const progress: number[] = []
|
||||||
|
const report = createProgressAggregator(100, p => progress.push(p))
|
||||||
|
|
||||||
|
report(1, 150)
|
||||||
|
expect(progress).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports 0 when the total is 0', () => {
|
||||||
|
const progress: number[] = []
|
||||||
|
const report = createProgressAggregator(0, p => progress.push(p))
|
||||||
|
|
||||||
|
report(1, 10)
|
||||||
|
expect(progress).toEqual([0])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Tracks bytes sent per part and reports overall progress (0..1) to the
|
||||||
|
* existing single-value progress callback. Parts upload concurrently, so each
|
||||||
|
* part reports its own running byte count and this sums them against the total.
|
||||||
|
*/
|
||||||
|
export function createProgressAggregator(
|
||||||
|
totalBytes: number,
|
||||||
|
setProgress: (progress: number) => void,
|
||||||
|
) {
|
||||||
|
const sentByPart = new Map<number, number>()
|
||||||
|
return function reportPartProgress(partNumber: number, bytesSent: number) {
|
||||||
|
sentByPart.set(partNumber, bytesSent)
|
||||||
|
let sum = 0
|
||||||
|
for (const value of sentByPart.values()) {
|
||||||
|
sum += value
|
||||||
|
}
|
||||||
|
setProgress(totalBytes > 0 ? Math.min(sum / totalBytes, 1) : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Multipart upload knobs. Tunable; part size must stay above the storage
|
||||||
|
* backend's minimum (R2/S3 require >= 5 MiB per part, except the last).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Target size per part. */
|
||||||
|
export const MULTIPART_PART_SIZE = 8 * 1024 * 1024 // 8 MiB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files below this skip multipart and use the single-shot POST. A file that
|
||||||
|
* would be a single part gains nothing from the multipart machinery.
|
||||||
|
*/
|
||||||
|
export const MULTIPART_MIN_FILE_SIZE = 16 * 1024 * 1024 // 16 MiB
|
||||||
|
|
||||||
|
/** Max parts uploaded concurrently. */
|
||||||
|
export const MULTIPART_CONCURRENCY = 3
|
||||||
|
|
||||||
|
/** Per-part upload attempts before the part (and the upload) fails. */
|
||||||
|
export const MULTIPART_MAX_ATTEMPTS = 3
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {planParts} from './planParts'
|
||||||
|
|
||||||
|
describe('planParts', () => {
|
||||||
|
it('splits an evenly divisible size into full parts', () => {
|
||||||
|
expect(planParts(20, 10)).toEqual([
|
||||||
|
{partNumber: 1, offset: 0, size: 10},
|
||||||
|
{partNumber: 2, offset: 10, size: 10},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the remainder in the last part', () => {
|
||||||
|
expect(planParts(25, 10)).toEqual([
|
||||||
|
{partNumber: 1, offset: 0, size: 10},
|
||||||
|
{partNumber: 2, offset: 10, size: 10},
|
||||||
|
{partNumber: 3, offset: 20, size: 5},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a single part when the file is smaller than a part', () => {
|
||||||
|
expect(planParts(5, 10)).toEqual([{partNumber: 1, offset: 0, size: 5}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no parts for a non-positive size', () => {
|
||||||
|
expect(planParts(0, 10)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('covers the whole file with no gaps or overlaps', () => {
|
||||||
|
const parts = planParts(1000, 128)
|
||||||
|
expect(parts[0].offset).toBe(0)
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
expect(parts[i].offset).toBe(parts[i - 1].offset + parts[i - 1].size)
|
||||||
|
}
|
||||||
|
const last = parts[parts.length - 1]
|
||||||
|
expect(last.offset + last.size).toBe(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for a non-positive part size', () => {
|
||||||
|
expect(() => planParts(100, 0)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {type PartPlan} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a file of `totalSize` bytes into parts of at most `partSize` bytes.
|
||||||
|
* The last part carries the remainder. Part numbers are 1-indexed. Returns an
|
||||||
|
* empty list for a non-positive size.
|
||||||
|
*/
|
||||||
|
export function planParts(totalSize: number, partSize: number): PartPlan[] {
|
||||||
|
if (partSize <= 0) {
|
||||||
|
throw new Error('partSize must be positive')
|
||||||
|
}
|
||||||
|
const parts: PartPlan[] = []
|
||||||
|
let offset = 0
|
||||||
|
let partNumber = 1
|
||||||
|
while (offset < totalSize) {
|
||||||
|
const size = Math.min(partSize, totalSize - offset)
|
||||||
|
parts.push({partNumber, offset, size})
|
||||||
|
offset += size
|
||||||
|
partNumber += 1
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {File} from 'expo-file-system'
|
||||||
|
|
||||||
|
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||||
|
import {type ChunkReader} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native chunk reader. Opens one file handle and seeks per read, so the video
|
||||||
|
* bytes are never all held in JS memory. Call `close` when the upload finishes.
|
||||||
|
*/
|
||||||
|
export function createChunkReader(video: CompressedVideo): ChunkReader {
|
||||||
|
const handle = new File(video.uri).open()
|
||||||
|
return {
|
||||||
|
read(offset, size) {
|
||||||
|
handle.offset = offset
|
||||||
|
return Promise.resolve(handle.readBytes(size))
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
handle.close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||||
|
import {type ChunkReader} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web chunk reader. Web compression already produces the full buffer, so this
|
||||||
|
* slices it in memory. Falls back to fetching the uri once if `bytes` is
|
||||||
|
* missing. `close` is a no-op.
|
||||||
|
*/
|
||||||
|
export function createChunkReader(video: CompressedVideo): ChunkReader {
|
||||||
|
let bytesPromise: Promise<ArrayBuffer> | null = null
|
||||||
|
const getBytes = () => {
|
||||||
|
if (video.bytes) {
|
||||||
|
return Promise.resolve(video.bytes)
|
||||||
|
}
|
||||||
|
if (!bytesPromise) {
|
||||||
|
bytesPromise = fetch(video.uri).then(res => res.arrayBuffer())
|
||||||
|
}
|
||||||
|
return bytesPromise
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async read(offset, size) {
|
||||||
|
const buffer = await getBytes()
|
||||||
|
return new Uint8Array(buffer, offset, size)
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* One part of a multipart upload. `partNumber` is 1-indexed to match the S3
|
||||||
|
* convention the backend uses.
|
||||||
|
*/
|
||||||
|
export type PartPlan = {
|
||||||
|
partNumber: number
|
||||||
|
offset: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of uploading a single part. `etag` is the value the storage backend
|
||||||
|
* (R2) returns for the part; the complete request lists these back to assemble
|
||||||
|
* the object.
|
||||||
|
*/
|
||||||
|
export type PartUploadResult = {
|
||||||
|
partNumber: number
|
||||||
|
etag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a byte range off the compressed video. Native opens a file handle and
|
||||||
|
* seeks; web slices an in-memory buffer. `close` releases the native handle and
|
||||||
|
* is a no-op on web.
|
||||||
|
*/
|
||||||
|
export type ChunkReader = {
|
||||||
|
read: (offset: number, size: number) => Promise<Uint8Array>
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads one part to storage and resolves with its ETag. This is the only
|
||||||
|
* protocol-specific seam - for the presigned R2 approach it does a direct PUT
|
||||||
|
* to the presigned URL and reads the ETag off the response. Injected into the
|
||||||
|
* orchestrator so the transport can be filled in once the backend lands.
|
||||||
|
*/
|
||||||
|
export type UploadPartFn = (args: {
|
||||||
|
part: PartPlan
|
||||||
|
chunk: Uint8Array
|
||||||
|
onProgress: (bytesSent: number) => void
|
||||||
|
signal: AbortSignal
|
||||||
|
}) => Promise<PartUploadResult>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import {type ChunkReader, type UploadPartFn} from './types'
|
||||||
|
import {uploadParts} from './uploadParts'
|
||||||
|
|
||||||
|
function fakeReader(): ChunkReader {
|
||||||
|
return {
|
||||||
|
read: (_offset, size) => Promise.resolve(new Uint8Array(size)),
|
||||||
|
close: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
{partNumber: 1, offset: 0, size: 10},
|
||||||
|
{partNumber: 2, offset: 10, size: 10},
|
||||||
|
{partNumber: 3, offset: 20, size: 5},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('uploadParts', () => {
|
||||||
|
it('uploads every part and returns results ordered by part number', async () => {
|
||||||
|
const uploadPart: UploadPartFn = ({part}) =>
|
||||||
|
Promise.resolve({
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
etag: `etag-${part.partNumber}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await uploadParts({
|
||||||
|
parts,
|
||||||
|
reader: fakeReader(),
|
||||||
|
uploadPart,
|
||||||
|
totalBytes: 25,
|
||||||
|
setProgress: () => {},
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.map(r => r.partNumber)).toEqual([1, 2, 3])
|
||||||
|
expect(results.map(r => r.etag)).toEqual(['etag-1', 'etag-2', 'etag-3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects the concurrency cap', async () => {
|
||||||
|
let active = 0
|
||||||
|
let maxActive = 0
|
||||||
|
const uploadPart: UploadPartFn = async ({part}) => {
|
||||||
|
active++
|
||||||
|
maxActive = Math.max(maxActive, active)
|
||||||
|
await new Promise(r => setTimeout(r, 5))
|
||||||
|
active--
|
||||||
|
return {partNumber: part.partNumber, etag: `e${part.partNumber}`}
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadParts({
|
||||||
|
parts,
|
||||||
|
reader: fakeReader(),
|
||||||
|
uploadPart,
|
||||||
|
totalBytes: 25,
|
||||||
|
setProgress: () => {},
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
concurrency: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(maxActive).toBeLessThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries a failing part and succeeds', async () => {
|
||||||
|
const attemptsByPart = new Map<number, number>()
|
||||||
|
const uploadPart: UploadPartFn = ({part}) => {
|
||||||
|
const n = (attemptsByPart.get(part.partNumber) ?? 0) + 1
|
||||||
|
attemptsByPart.set(part.partNumber, n)
|
||||||
|
if (part.partNumber === 2 && n === 1) {
|
||||||
|
return Promise.reject(new Error('transient'))
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
etag: `e${part.partNumber}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await uploadParts({
|
||||||
|
parts,
|
||||||
|
reader: fakeReader(),
|
||||||
|
uploadPart,
|
||||||
|
totalBytes: 25,
|
||||||
|
setProgress: () => {},
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attemptsByPart.get(2)).toBe(2)
|
||||||
|
expect(results).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws after exhausting attempts', async () => {
|
||||||
|
const uploadPart: UploadPartFn = () =>
|
||||||
|
Promise.reject(new Error('always fails'))
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadParts({
|
||||||
|
parts,
|
||||||
|
reader: fakeReader(),
|
||||||
|
uploadPart,
|
||||||
|
totalBytes: 25,
|
||||||
|
setProgress: () => {},
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
maxAttempts: 2,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('always fails')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports progress that reaches 1 when all parts complete', async () => {
|
||||||
|
const progress: number[] = []
|
||||||
|
const uploadPart: UploadPartFn = ({part, chunk, onProgress}) => {
|
||||||
|
onProgress(chunk.byteLength)
|
||||||
|
return Promise.resolve({
|
||||||
|
partNumber: part.partNumber,
|
||||||
|
etag: `e${part.partNumber}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadParts({
|
||||||
|
parts,
|
||||||
|
reader: fakeReader(),
|
||||||
|
uploadPart,
|
||||||
|
totalBytes: 25,
|
||||||
|
setProgress: p => progress.push(p),
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
concurrency: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(progress[progress.length - 1]).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {AbortError} from '#/lib/async/cancelable'
|
||||||
|
import {createProgressAggregator} from './aggregateProgress'
|
||||||
|
import {MULTIPART_CONCURRENCY, MULTIPART_MAX_ATTEMPTS} from './constants'
|
||||||
|
import {
|
||||||
|
type ChunkReader,
|
||||||
|
type PartPlan,
|
||||||
|
type PartUploadResult,
|
||||||
|
type UploadPartFn,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads every part with a concurrency cap and per-part retry, aggregating
|
||||||
|
* byte progress into `setProgress`. Reads each chunk lazily just before its
|
||||||
|
* upload so only `concurrency` chunks are in memory at once. Resolves with the
|
||||||
|
* part results ordered by part number, ready for the complete request.
|
||||||
|
*
|
||||||
|
* `uploadPart` is the transport seam - it stays stubbed until the presigned R2
|
||||||
|
* PUT is wired up.
|
||||||
|
*/
|
||||||
|
export async function uploadParts({
|
||||||
|
parts,
|
||||||
|
reader,
|
||||||
|
uploadPart,
|
||||||
|
totalBytes,
|
||||||
|
setProgress,
|
||||||
|
signal,
|
||||||
|
concurrency = MULTIPART_CONCURRENCY,
|
||||||
|
maxAttempts = MULTIPART_MAX_ATTEMPTS,
|
||||||
|
}: {
|
||||||
|
parts: PartPlan[]
|
||||||
|
reader: ChunkReader
|
||||||
|
uploadPart: UploadPartFn
|
||||||
|
totalBytes: number
|
||||||
|
setProgress: (progress: number) => void
|
||||||
|
signal: AbortSignal
|
||||||
|
concurrency?: number
|
||||||
|
maxAttempts?: number
|
||||||
|
}): Promise<PartUploadResult[]> {
|
||||||
|
const reportPartProgress = createProgressAggregator(totalBytes, setProgress)
|
||||||
|
const results: PartUploadResult[] = new Array(parts.length)
|
||||||
|
|
||||||
|
let nextIndex = 0
|
||||||
|
async function worker() {
|
||||||
|
while (true) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
const index = nextIndex++
|
||||||
|
if (index >= parts.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const part = parts[index]
|
||||||
|
const chunk = await reader.read(part.offset, part.size)
|
||||||
|
results[index] = await uploadPartWithRetry({
|
||||||
|
part,
|
||||||
|
chunk,
|
||||||
|
uploadPart,
|
||||||
|
maxAttempts,
|
||||||
|
signal,
|
||||||
|
onProgress: bytesSent => reportPartProgress(part.partNumber, bytesSent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from(
|
||||||
|
{length: Math.min(concurrency, parts.length)},
|
||||||
|
() => worker(),
|
||||||
|
)
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPartWithRetry({
|
||||||
|
part,
|
||||||
|
chunk,
|
||||||
|
uploadPart,
|
||||||
|
maxAttempts,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
part: PartPlan
|
||||||
|
chunk: Uint8Array
|
||||||
|
uploadPart: UploadPartFn
|
||||||
|
maxAttempts: number
|
||||||
|
signal: AbortSignal
|
||||||
|
onProgress: (bytesSent: number) => void
|
||||||
|
}): Promise<PartUploadResult> {
|
||||||
|
let lastError: unknown
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await uploadPart({part, chunk, onProgress, signal})
|
||||||
|
} catch (err) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
lastError = err
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await delay(500 * 2 ** (attempt - 1), signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number, signal: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
|
resolve()
|
||||||
|
}, ms)
|
||||||
|
function onAbort() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new AbortError())
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, {once: true})
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user