Lifecycle

Prev Next

Lifecycle

Internal State Machine

The Upload Engine uses an explicit, enforced state machine with 10 internal states. Every state transition is validated by assertTransition() — illegal transitions throw at runtime.

Image

Transition Table (from source)

// UploadStateMachine.ts — STATE_TRANSITIONS
const STATE_TRANSITIONS = {
  idle: ['initializing', 'aborted'],
  initializing: ['uploading', 'needs-attention', 'aborted'],
  uploading: ['paused', 'retrying', 'finalizing', 'needs-attention', 'aborted'],
  paused: ['uploading', 'aborted'],
  retrying: ['uploading', 'needs-attention', 'aborted'],
  finalizing: ['committing', 'needs-attention', 'aborted'],
  committing: ['completed', 'needs-attention'],
  completed: [], // terminal
  'needs-attention': ['uploading', 'aborted'],
  aborted: [], // terminal
};

Key functions:

Function Purpose
canTransition(from, to) Returns boolean — is transition legal?
assertTransition(from, to) Throws if illegal — used in UploadSession.moveTo()
transition(from, to) Validates + returns to — makes call sites explicit
isTerminalState(state) completed or aborted
requiresAttention(state) needs-attention only

End-to-End Lifecycle

Phase 1: Start

Feature Module → UploadEngine.start(options) → UploadSession(idle)

  • UploadEngineImpl.start() creates a new UploadSession with state idle
  • session.startInitialization() transitions idle → initializing and emits UPLOAD_STARTED
  • Chunk config is applied (caller override → persisted → default 5 MB)
  • Session is enqueued into UploadQueue (max 5 concurrent)

Phase 2: Initialization (Worker)

UploadQueue drains → startWorker() → UploadWorker.run() → initialize()

Fresh upload path:

  1. UploadService.initMultipartUpload() calls /v3.0/.../init?type=<contentType>
  2. Backend returns { uploadId, key, bucket, prefixUrl, chunkSize, chunks }
  3. session.setUploadServiceData(...) stores multipart metadata
  4. UploadPlanner is instantiated with server-authoritative chunkSize
  5. session.startUploading() transitions initializing → uploading

Resume path (existing uploadServiceData):

  1. Completed parts are loaded from snapshot into Set<number> + Map<number, string>
  2. UploadPlanner is instantiated with persisted chunkSize
  3. session.startUploading() transitions paused → uploading or needs-attention → uploading

Phase 3: Chunk Upload Loop

Image

Chunk upload details:

  • Each chunk: file.slice(startByte, endByte) → PUT to presigned S3 URL
  • ETag extracted from response header
  • Progress = completedPartNumbers.size / totalChunks × 100 (monotonic, clamped 0–100)
  • On failure: retry up to 3 times (500ms linear backoff) via RetryPolicy
  • During retry: uploading → retrying → uploading
  • If retries exhausted: session.fail('upload', message)needs-attention

Parallel chunk mode (enableParallelChunkProcessing = true):

  • Up to PARALLEL_CHUNK_LIMIT = 5 concurrent chunk PUTs
  • Uses Promise.race() sliding window pattern
  • Window advances only past contiguously completed parts (getContiguousCompletedPart())

Phase 4: Completion

uploading → finalizing → committing → completed

  1. session.startFinalizing() transitions uploading → finalizing
  2. UploadService.completeMultipartUpload() sends sorted completedParts to /complete
  3. On success: session.startCommitting()committing
  4. session.complete(fileUrl) sets fileUrl = prefixUrl + key, transitions to completed
  5. Emits UPLOAD_COMPLETED with fileUrl

On failure: 2 retries with 1000ms linear backoff. If exhausted → session.fail('completion', msg) + persist for later resume.

Phase 5: Completion Handler

completed → runCompletionHandler(fileUrl)

  • If onComplete was provided, it is called with { fileUrl, file, context }
  • 1 retry attempt on failure
  • Handler failure → session.fail('completion', msg)needs-attention + persist
  • Handler failure does not invalidate the upload — fileUrl remains valid

Phase 6: Pause

uploading → paused

  1. worker.requestPause() sets pauseRequested = true and aborts the AbortController
  2. Worker's control flow catches PauseError (internal, never leaks)
  3. session.pause() transitions uploading → paused, emits UPLOAD_PAUSED
  4. Current state is persisted to sessionStorage
  5. Queue slot is freed via queue.finish(uploadId)

Phase 7: Resume

paused → (re-enqueue) → uploading

  1. UploadEngine.resume() restores chunk config and emits UPLOAD_RESUMED
  2. Session is re-enqueued into UploadQueue
  3. Worker's initialize() detects existing uploadServiceData → takes resume path
  4. session.startUploading() transitions paused → uploading
  5. Upload continues from getContiguousCompletedPart() + 1

Phase 8: Abort

<any non-terminal> → aborted

  1. worker.abort() aborts the AbortController
  2. session.abort() is idempotent — returns silently if already terminal
  3. Transitions to aborted, emits UPLOAD_ABORTED
  4. handleEvent() removes persistence data and deletes session from sessions Map
  5. Queue slot is freed — abort is final

Phase 9: Rehydration (App Reload)

sessionStorage → UploadPersistence.loadForTenant() → sessions Map

  1. On construction, UploadEngineImpl calls rehydrateFromPersistence()
  2. Loads persisted uploads for current tenant (CookieManager.get('siteId') + CookieManager.get('site'))
  3. Only paused and needs-attention states are rehydrated (safety filter)
  4. fromPersistedStateAdapter() creates UploadSession instances from persisted data
  5. Sessions appear in list() — user can resume or abort

:::warning
The File object is not persisted (not serializable). Rehydrated uploads require the user to resume manually — the worker will use persisted uploadServiceData and completedParts to continue from where it left off.
:::

Control Flow Errors

The worker uses two internal exception types for control flow:

class PauseError extends Error {} // thrown when pauseRequested is detected
class AbortError extends Error {} // thrown when AbortController is aborted

These never leak outside UploadWorker.run(). The outer try/catch/finally handles:

  • PauseError → return silently (state already transitioned)
  • Abort signal detected → call session.abort()
  • Any other error → session.fail('upload', msg) → persist → notify onError
  • finally → always calls onDone() to free the queue slot