Repository navigation
feat: support partial (multi-request) scene uploads #504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
LautaroPetaccio
wants to merge
59
commits into
main
Choose a base branch
from
feat/partial-deployments
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 8 commits
Commits
Show all changes
59 commits
Select commit
Hold shift + click to select a range
dfd4100
feat: support partial (multi-request) scene uploads
LautaroPetaccio e8b2ea6
test: cover parallel partial-upload staging requests
LautaroPetaccio 9057b33
fix: address partial-upload review findings
LautaroPetaccio 4a16f90
perf: skip the permission check on resume batches of a partial upload
LautaroPetaccio 4108a27
test: pin that losing the name permission mid-upload rejects the fina…
LautaroPetaccio 3667eed
fix: address second-review findings on the partial-upload path
LautaroPetaccio 6cce488
fix: guard GC pending-key projection and store all uploaded files
LautaroPetaccio 91b2ee5
feat: order-aware pending replacement and a per-deployer staging cap
LautaroPetaccio 597d5e3
fix: make deployment ordering and the per-deployer cap atomic
LautaroPetaccio 4cf315e
fix: memory, GC, and cap-accounting hardening for partial uploads
LautaroPetaccio ab00850
feat: finalization lease so only one request finalizes a completed up…
LautaroPetaccio 0b644ce
refactor: split partial-upload component types into per-component typ…
LautaroPetaccio 5fcc33d
fix: address partial-deployment review findings
LautaroPetaccio cda077e
fix: harden partial-deployment finalize, GC, and staging hot path
LautaroPetaccio 5a6eb94
Merge branch 'main' into feat/partial-deployments
LautaroPetaccio 02c2f09
fix: correct persisted size and cancellation on the partial finalize …
LautaroPetaccio 70602f7
fix: harden the partial-resume gate, GC re-check index, and duplicate…
LautaroPetaccio ed1bdb2
fix: harden owner reconciliation, degrade paths, and API-contract acc…
LautaroPetaccio eda87fc
fix: complete the whitelist casing fix and close partial-deploy edge …
LautaroPetaccio b84df4d
Merge branch 'main' into feat/partial-deployments
LautaroPetaccio b1e77de
feat: key partial uploads by entity id and let overlapping uploads co…
LautaroPetaccio a68f049
fix: close partial upload races and lock pool starvation
LautaroPetaccio 436725a
fix: reject batches from another signer on a live upload
LautaroPetaccio bc09efa
fix: keep unadmitted batches and gc waits off upload quotas and locks
LautaroPetaccio d572027
fix: queue gc writers and retry a saturated lock pool
LautaroPetaccio d7a32b0
fix: build the gc index concurrently and bound gc writer waits
LautaroPetaccio 818929e
fix: retry the lock pool when opening a connection times out
LautaroPetaccio 9d0c036
fix: reuse lock connections after a failed operation
LautaroPetaccio 70f26f8
fix: keep vanilla deploys independent of pending partial uploads
LautaroPetaccio e8ad942
fix: apply migrations before the server starts serving
LautaroPetaccio dcd5fc0
fix: tie completion receipts to the entity's latest publication
LautaroPetaccio d751298
fix: serialize migrations across instances with an advisory lock
LautaroPetaccio e99706a
feat: reject a partial query flag without the partial form field
LautaroPetaccio dd59b7b
fix: run migrations on the session that holds the migrations lock
LautaroPetaccio a6ada4d
fix: keep partial uploads within their fixed lifetime
LautaroPetaccio 91040cd
fix: bound each source's in-flight uploads before reading the body
LautaroPetaccio a2d7b49
fix: start partial upload lifetimes at their first request's arrival
LautaroPetaccio 306afac
fix: try the exclusive content lock instead of queuing on it
LautaroPetaccio 3280dac
test: scope the deployments counter assertions to that metric
LautaroPetaccio 1f53ca8
feat: make the trusted client ip header configurable
LautaroPetaccio c2f2e2e
fix: say exactly why a request timed out in 408 responses
LautaroPetaccio ae2f218
fix: answer full partial-upload quotas with 429 and retry-after
LautaroPetaccio af09cd4
feat: derive upload concurrency and file limits from the byte budget
LautaroPetaccio ffa10d9
fix: answer every partial batch for a published entity with 200
LautaroPetaccio 39b38c8
feat: expire partial uploads after 1 hour and clean them every 5 minutes
LautaroPetaccio 014177b
feat: add partial upload lifecycle, quota and cleanup metrics
LautaroPetaccio 64c7125
fix: reject repeated form field names in multipart uploads
LautaroPetaccio a958049
fix: never create or charge a partial upload past its deadline
LautaroPetaccio e53f0bc
test: cover categories sent as repeated settings fields
LautaroPetaccio da53fda
fix: answer 400 for partial uploads that can never fit a quota
LautaroPetaccio e985749
docs: correct partial upload status codes, rollout and ordering
LautaroPetaccio dfcdfd7
fix: stop waiting for the content lock when a settings client disconn…
LautaroPetaccio daf4f8a
chore: drop the unreleased world_scenes updated_at index migration
LautaroPetaccio c1289f8
chore: tidy partial upload code and test responses
LautaroPetaccio 9b93035
fix: cap post /entities form fields to what a deployment sends
LautaroPetaccio eb98372
feat: charge partial uploads only for the bytes they store
LautaroPetaccio daad8d7
fix: validate deployments before taking the content lock
LautaroPetaccio 7131879
fix: answer multipart size and count limits with 413
LautaroPetaccio 67e9015
feat: cap world scene size at max_scene_size
LautaroPetaccio File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| import SQL from 'sql-template-strings' | ||
| import { Entity } from '@dcl/schemas' | ||
| import { InvalidRequestError } from '@dcl/http-commons' | ||
| import { AppComponents, IPendingScenesManager, PendingScene, UpsertPendingScene } from '../types' | ||
|
|
||
| const DEFAULT_PENDING_DEPLOYMENT_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours | ||
|
|
||
| type PendingSceneRow = { | ||
| entity_id: string | ||
| world_name: string | ||
| parcels: string[] | ||
| entity: Entity | ||
| deployer: string | ||
| created_at: Date | ||
| updated_at: Date | ||
| } | ||
|
|
||
| function toPendingScene(row: PendingSceneRow): PendingScene { | ||
| return { | ||
| entityId: row.entity_id, | ||
| worldName: row.world_name, | ||
| parcels: row.parcels, | ||
| entity: row.entity, | ||
| deployer: row.deployer, | ||
| createdAt: row.created_at, | ||
| updatedAt: row.updated_at | ||
| } | ||
| } | ||
|
|
||
| export async function createPendingScenesManager( | ||
| components: Pick<AppComponents, 'config' | 'database' | 'logs'> | ||
| ): Promise<IPendingScenesManager> { | ||
| const { config, database, logs } = components | ||
| const logger = logs.getLogger('pending-scenes-manager') | ||
| const ttlMs = (await config.getNumber('PENDING_DEPLOYMENT_TTL')) ?? DEFAULT_PENDING_DEPLOYMENT_TTL_MS | ||
|
|
||
| async function getByEntityId(entityId: string): Promise<PendingScene | undefined> { | ||
| const cutoff = new Date(Date.now() - ttlMs) | ||
| const result = await database.query<PendingSceneRow>( | ||
| SQL`SELECT entity_id, world_name, parcels, entity, deployer, created_at, updated_at | ||
| FROM pending_scenes | ||
| WHERE entity_id = ${entityId} AND created_at >= ${cutoff} | ||
| LIMIT 1` | ||
| ) | ||
| return result.rows.length > 0 ? toPendingScene(result.rows[0]) : undefined | ||
| } | ||
|
|
||
| async function upsert(input: UpsertPendingScene): Promise<PendingScene> { | ||
| const worldName = input.worldName.toLowerCase() | ||
| const expiryCutoff = new Date(Date.now() - ttlMs) | ||
|
|
||
| return await database.withAsyncContextTransaction(async () => { | ||
| // Serialize the "replace overlapping + insert" critical section per world (also across | ||
| // processes) so two concurrent uploads for the same world+parcels can't both insert. | ||
| await database.query(SQL`SELECT pg_advisory_xact_lock(hashtextextended(${'pending_scenes:' + worldName}, 0))`) | ||
|
|
||
| // The single pending slot per parcel set goes to the NEWEST scene (Decentraland deployment | ||
| // ordering: greater entity.timestamp, tie broken by greater entity id). Reject rather than | ||
| // replace when a strictly-newer overlapping upload is already in flight, so a stale/older upload | ||
| // can't evict a newer competitor's staged content (and two clients can't ping-pong evicting each | ||
| // other). A resume (same entity id) is excluded and never conflicts with itself. | ||
| const newer = await database.query(SQL` | ||
| SELECT 1 FROM pending_scenes | ||
| WHERE world_name = ${worldName} | ||
| AND parcels && ${input.parcels}::text[] | ||
| AND entity_id != ${input.entityId} | ||
| AND created_at >= ${expiryCutoff} | ||
| AND ( (entity->>'timestamp')::bigint > ${input.entity.timestamp} | ||
| OR ((entity->>'timestamp')::bigint = ${input.entity.timestamp} AND entity_id > ${input.entityId}) ) | ||
| LIMIT 1 | ||
| `) | ||
| if (newer.rowCount > 0) { | ||
| throw new InvalidRequestError('A newer partial upload is already in progress for one or more of these parcels.') | ||
| } | ||
|
|
||
| // Purge expired rows and replace any non-expired pending scene of this world whose parcels | ||
| // overlap the new one (a different entity id). Having rejected the newer-conflict above, every | ||
| // remaining overlapping row is strictly older, so replacing it is the intended "newest wins". | ||
| await database.query(SQL` | ||
| DELETE FROM pending_scenes | ||
| WHERE created_at < ${expiryCutoff} | ||
| OR (world_name = ${worldName} AND parcels && ${input.parcels}::text[] AND entity_id != ${input.entityId}) | ||
| `) | ||
|
|
||
| const result = await database.query<PendingSceneRow>(SQL` | ||
| INSERT INTO pending_scenes (entity_id, world_name, parcels, entity, deployer, created_at, updated_at) | ||
| VALUES (${input.entityId}, ${worldName}, ${input.parcels}::text[], ${input.entity}::jsonb, ${input.deployer.toLowerCase()}, now(), now()) | ||
| ON CONFLICT (entity_id) DO UPDATE SET updated_at = now() | ||
| RETURNING entity_id, world_name, parcels, entity, deployer, created_at, updated_at | ||
| `) | ||
| return toPendingScene(result.rows[0]) | ||
| }) | ||
| } | ||
|
|
||
| async function deleteByEntityId(entityId: string): Promise<void> { | ||
| await database.query(SQL`DELETE FROM pending_scenes WHERE entity_id = ${entityId}`) | ||
| } | ||
|
|
||
| async function countActiveByDeployer(deployer: string): Promise<number> { | ||
| const cutoff = new Date(Date.now() - ttlMs) | ||
| const result = await database.query<{ count: string }>( | ||
| SQL`SELECT COUNT(*) as count FROM pending_scenes | ||
| WHERE deployer = ${deployer.toLowerCase()} AND created_at >= ${cutoff}` | ||
| ) | ||
| return parseInt(result.rows[0].count, 10) | ||
| } | ||
|
|
||
| async function deleteExpired(): Promise<number> { | ||
| const cutoff = new Date(Date.now() - ttlMs) | ||
| const result = await database.query(SQL`DELETE FROM pending_scenes WHERE created_at < ${cutoff}`) | ||
| const removed = result.rowCount ?? 0 | ||
| if (removed > 0) { | ||
| logger.info(`Removed ${removed} expired pending scene(s)`) | ||
| } | ||
| return removed | ||
| } | ||
|
|
||
| async function getActivePendingKeys(): Promise<Set<string>> { | ||
| const cutoff = new Date(Date.now() - ttlMs) | ||
| // Project only the content hashes out of the entity JSONB instead of shipping every pending | ||
| // scene's full manifest: GC calls this once per delete batch, so on a large sweep the payload | ||
| // size matters more than the (tiny) row count. The jsonb_typeof guard keeps a row whose `content` | ||
| // is absent, null, or a non-array from erroring `jsonb_array_elements` ('cannot extract elements | ||
| // from a scalar') — one such row would otherwise fail the whole query and wedge GC server-wide. | ||
| const result = await database.query<{ entity_id: string; hashes: string[] | null }>( | ||
| SQL`SELECT entity_id, | ||
| ARRAY( | ||
| SELECT jsonb_array_elements(entity->'content')->>'hash' | ||
| WHERE jsonb_typeof(entity->'content') = 'array' | ||
| ) AS hashes | ||
| FROM pending_scenes | ||
| WHERE created_at >= ${cutoff}` | ||
| ) | ||
| const keys = new Set<string>() | ||
| for (const row of result.rows) { | ||
| // The staged entity JSON, its auth-chain blob, and every content file it references are all | ||
| // referenced by the in-flight upload even though no world_scenes row exists yet. | ||
| keys.add(row.entity_id) | ||
| keys.add(`${row.entity_id}.auth`) | ||
| for (const hash of row.hashes ?? []) { | ||
| keys.add(hash) | ||
| } | ||
| } | ||
| return keys | ||
| } | ||
|
|
||
| return { getByEntityId, upsert, deleteByEntityId, deleteExpired, getActivePendingKeys, countActiveByDeployer } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.