Skip to content

feat: add partial (batched) deployment support - #491

Open
LautaroPetaccio wants to merge 10 commits into
masterfrom
feat/partial-deployments
Open

LautaroPetaccio wants to merge 10 commits into
masterfrom
feat/partial-deployments

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds deployPartial, which deploys an entity across several POST /entities requests following decentraland/adr#325, for entities too large for a single request. deploy is unchanged.

How

  • Plans the first round with /available-content, then packs the missing files into batches (first-fit-decreasing, 100 MiB per request by default via maxBatchSizeBytes). The first request carries the entity file; the rest go through a bounded worker pool (concurrency, default 2).
  • Later rounds upload the server's latest 202 missing list rather than re-querying availability; the first 200 wins.
  • Retries 408, 409, 429 (never sooner than Retry-After), 5xx and network errors with exponential backoff. Other 4xx responses, including quota 400s, are terminal. It gives up after maxResumeAttempts rounds without progress.
  • Against a server without partial support, a single-batch deployment still succeeds and a multi-batch one rejects with PartialDeploymentNotSupportedError.

API

const result = await client.deployPartial(
  { entityId, authChain, files },
  { maxBatchSizeBytes, concurrency, maxResumeAttempts, resumeDelay, onProgress }
)

New exports: PartialDeploymentOptions, PartialDeploymentProgress, PartialDeploymentResult, DeploymentError, PartialDeploymentValidationError, PartialDeploymentNotSupportedError, splitIntoBatches, DEFAULT_MAX_BATCH_SIZE_BYTES.

How to test

yarn test: test/unit/batching.spec.ts covers the batcher and test/PartialDeployment.spec.ts the flow (missing-list rounds, retries and Retry-After, terminal 400s, stalls, old-server detection, progress).

Additive API only (semver minor). The 202 type stays local until decentraland/catalyst-api-specs#171 is released. The SDK integration is decentraland/js-sdk-toolchain#1635.

add a new `deployPartial` method that uploads an entity's content across
several POST /entities requests instead of one, for entities too large for a
single request. the regular `deploy` is unchanged.

- splits content into size-bounded batches (first-fit-decreasing, default
  50 MiB per request) and uploads them with `partial=true`
- the entity file travels only in the first request (the server needs the
  manifest before the parallel batches); remaining batches upload through a
  bounded worker pool
- skips content already on the server via /available-content, and resumes on
  network/5xx failures by re-querying and re-uploading only what is missing
- returns the creationTimestamp once the server finalizes; surfaces
  PartialDeploymentValidationError on a 400 and
  PartialDeploymentNotSupportedError against a server without partial support
@github-actions

github-actions Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Test this pull request

  • The package can be tested by running
    yarn upgrade "https://sdk-team-cdn.decentraland.org/@dcl/catalyst-client/branch/feat/partial-deployments/dcl-catalyst-client-22.0.1-36576561831.commit-8381294.tgz"

when a batch's 200 finalizes the deployment the worker pool aborts the other
in-flight batches; their abort errors were propagating and triggering a
spurious resume cycle (an extra /available-content query, request and delay).
swallow the abort once the session has already succeeded so the parallel path
finalizes in a single session. adds a regression test for the parallel worker
pool.
- detect a server without partial support via either server's missing-content
  message: worlds-content-server's "neither present in the storage" and
  catalyst's (via @dcl/content-validator) "was not uploaded or previously
  available". previously only the worlds phrasing matched, so a multi-batch
  deploy against an old catalyst threw a terminal validation error instead of
  PartialDeploymentNotSupportedError and callers never fell back to deploy()
- honor the caller's AbortSignal: it was overwritten by the internal pool
  controller (or undefined) via mergeRequestOptions, so cancellation was
  ignored. each request now aborts when either the caller or the pool aborts
- treat 429 (and 5xx / network) as retryable so a rate-limited staging request
  resumes instead of failing terminally; only other 4xx are terminal

adds tests for old-catalyst detection, 429 resume, and caller-signal propagation.
the infrastructure in front of the content servers times out requests larger
than ~200MB, so the default batch cap moves from 50 MiB to 100 MiB — half the
requests for large scenes while keeping a ~2x safety margin (verified: the
first request is entity JSON + the fullest batch, every other request is one
batch, and a batch can only exceed the cap when a single file does).

hardening from the follow-up review:
- fail fast when a single file exceeds the request cap: files cannot be split
  across requests, so such an upload previously burned every resume attempt
  re-uploading hundreds of MB before failing with an opaque network error
- abort promptly: check the caller signal at session entry (covers aborts
  landing during the resume backoff), carry it on the available-content query,
  and combine it with the pool controller once per session instead of per
  request (avoids accumulating abort listeners on large uploads)
- exponential backoff between resume attempts, so 429 rate-limit retries have
  a chance of landing outside the limit window
- consume the body of 202 responses: with native fetch an unread response
  pins the undici socket and buffered bytes, and a large upload produces one
  202 per non-finalizing batch
- treat a finalizing 200 whose body fails to parse (empty body, proxy
  rewrite) as the success it is, with a fallback timestamp, instead of
  burning every resume attempt re-observing the already-deployed entity and
  reporting failure for a deployment that succeeded
- cap the exponential resume backoff at 60s so a large maxResumeAttempts
  can't grow 2^attempt into effectively-hung multi-hour sleeps
- detach the combined abort-signal listeners when a session's worker pool
  drains, so a long-lived caller signal reused across many uploads doesn't
  accumulate one listener per session
- 200 with an unparseable body: only fabricate a fallback timestamp on a
  genuine parse failure. If the body read was cancelled (a sibling worker
  finalized and aborted the pool, or the caller aborted), let the abort
  propagate — otherwise the loser of a concurrent finalize fabricates a
  client-clock timestamp that overwrites the winner's real server timestamp,
  and a caller cancellation mid-read is masked as success
- worker pool: abort the controller and await all workers to settle in the
  finally. A retryable worker throw rejected Promise.all without aborting, so
  sibling requests kept uploading uncancelable while the resume loop started a
  new overlapping session
- drain 202 bodies via text() instead of json() (drain without the wasted parse)
- resume backoff cap never drops below the caller's configured resumeDelay, so
  a caller that set a large base delay to respect an upstream limiter keeps it

adds a regression test for the concurrent-finalize / aborted-loser timestamp.
… fail fast on undeliverable content

- deliver cancellation via an abortController, not a signal: the default
  @dcl/fetch-component overwrites a caller-supplied signal with its own
  internal controller and only honors an abortController option, so an
  in-flight request (caller abort, or the pool's first-200-wins abort) was
  never actually cancelled on the wire — only the next batch was suppressed
- a concurrent win now takes precedence over a sibling worker's retryable
  failure: previously a retryable throw rejected Promise.all and propagated
  before the win was returned, discarding a completed deployment and
  triggering a spurious resume
- honor a server Retry-After header as the resume backoff floor (capped), so
  a redeploy inside a rate-limit window waits the window out instead of
  exhausting the ~7s exponential backoff and failing terminally
- only reject an oversized single file when it actually needs uploading; an
  already-stored oversized file (shared with a prior deploy) needs zero bytes
  and must not block the deployment (also fixes the doc contradiction)
- read the 202 { missing } body and fail fast with a terminal, diagnostic
  error when a referenced hash is absent from the provided files, instead of
  looping through full re-upload sessions to the same dead end
Aligns deployPartial with ADR-325: /available-content only plans the
first round, and when every batch is accepted without publishing, the
client sends the hashes from the latest 202 missing list (or an empty
batch when nothing is listed) instead of re-querying availability.
Retries 408, 409, 429 and 5xx with backoff floored at Retry-After and
gives up with a DeploymentError after maxResumeAttempts rounds without
progress. The public API is unchanged.
deployPartial now posts every batch to /entities?partial=true while
keeping the partial=true form field, so the server can tell a batch from
a regular deployment before reading the body (a Catalyst whose daily
quota for the source is spent otherwise answers 429 before parsing).
…nt waits

Quota rejections now answer 429 with a Retry-After that can be hours away (an upload slot frees only when older uploads expire). The resume loop capped its wait at 60 s, so it retried before Retry-After, which cannot succeed, and failed after its attempts. A Retry-After longer than the backoff ceiling now ends the call with PartialDeploymentRetryLaterError, carrying the server's reason and when to retry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant