Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/entities/CheckScenes/task/handleWorldScenesUndeployment.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { WorldScenesUndeploymentEvent } from "@dcl/schemas/dist/platform/events/world"
import logger from "decentraland-gatsby/dist/entities/Development/logger"

import PlaceModel from "../../Place/model"
import { notifyError } from "../../Slack/utils"
import WorldModel from "../../World/model"

/**
* Handles WorldScenesUndeploymentEvent from the worlds content server.
* Atomically deletes place records for undeployed scenes and refreshes
* the world's deployment-derived fields from the next-latest remaining scene.
* Deletes the place records corresponding to the undeployed scenes,
* identified by world name and each scene's base parcel.
*/
export async function handleWorldScenesUndeployment(
event: WorldScenesUndeploymentEvent
Expand Down Expand Up @@ -41,14 +41,14 @@ export async function handleWorldScenesUndeployment(
)}`
)

await WorldModel.deleteWorldScenesAndRefresh(
await PlaceModel.deleteByWorldIdAndPositions(
worldName,
basePositions,
event.timestamp
)

loggerExtended.log(
`Deleted place records and refreshed world: ${worldName} at positions: ${basePositions.join(
`Deleted place records for world: ${worldName} at positions: ${basePositions.join(
", "
)}`
)
Expand Down
9 changes: 3 additions & 6 deletions src/entities/CheckScenes/task/taskRunnerSqs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import CategoryModel from "../../Category/model"
import { DecentralandCategories } from "../../Category/types"
import PlaceModel from "../../Place/model"
import { PlaceAttributes } from "../../Place/types"
import { getThumbnailFromContentDeployment } from "../../Place/utils"
import PlaceCategories from "../../PlaceCategories/model"
import PlaceContentRatingModel from "../../PlaceContentRating/model"
import PlacePositionModel from "../../PlacePosition/model"
Expand Down Expand Up @@ -84,17 +83,15 @@ export async function taskRunnerSqs(job: DeploymentToSqs) {
const isOptOut =
!!contentEntityScene?.metadata?.worldConfiguration?.placesConfig?.optOut

const worldImage = getThumbnailFromContentDeployment(contentEntityScene, {
url: job.contentServerUrls![0],
})

// Insert the world only if it doesn't already exist.
// If it already exists (configured via settings or a previous deployment),
// its data is left untouched.
const worldId = await WorldModel.insertWorldIfNotExists({
world_name: worldName,
title:
contentEntityScene?.metadata?.display?.title?.slice(0, 50) || undefined,
description:
contentEntityScene?.metadata?.display?.description || undefined,
image: worldImage,
content_rating:
(contentEntityScene?.metadata?.policy
?.contentRating as SceneContentRating) || undefined,
Expand Down
3 changes: 1 addition & 2 deletions src/entities/Destination/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from "./types"
import PlaceModel from "../Place/model"
import { HotScene, PlaceListOrderBy } from "../Place/types"
import { DEFAULT_WORLD_IMAGE } from "../shared/constants"
import WorldModel from "../World/model"

/**
Expand All @@ -37,7 +36,7 @@ const PLACES_DESTINATION_SELECT = SQL`
* using the lateral join (lp) for place-derived fields.
*/
const WORLDS_DESTINATION_SELECT = SQL`
w.id, w.title, w.description, COALESCE(w.image, ${DEFAULT_WORLD_IMAGE}) as image,
w.id, w.title, w.description, COALESCE(w.image, lp.image) as image,
w.owner, w.world_name,
w.content_rating, w.categories, w.likes, w.dislikes, w.favorites,
w.like_rate, w.like_score, w.disabled, w.disabled_at,
Expand Down
4 changes: 2 additions & 2 deletions src/entities/Place/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
} from "./types"
import { SceneStats, SceneStatsMap } from "../../api/DataTeam"
import toCanonicalPosition from "../../utils/position/toCanonicalPosition"
import { DEFAULT_WORLD_IMAGE } from "../shared/constants"
import { AnyEntityAttributes } from "../shared/entityTypes"

const DECENTRALAND_URL =
Expand Down Expand Up @@ -128,7 +127,8 @@ export function getThumbnailFromContentDeployment(
}

if (!thumbnail && deployment?.metadata?.worldConfiguration) {
thumbnail = DEFAULT_WORLD_IMAGE
thumbnail =
"https://peer.decentraland.org/content/contents/bafkreidj26s7aenyxfthfdibnqonzqm5ptc4iamml744gmcyuokewkr76y"
} else if (!thumbnail) {
thumbnail = Land.getInstance().getMapImage({
selected: positions,
Expand Down
177 changes: 32 additions & 145 deletions src/entities/World/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
limit,
offset,
table,
tsquery,
values,
} from "decentraland-gatsby/dist/entities/Database/utils"
import { oneOf } from "decentraland-gatsby/dist/entities/Schema/utils"
Expand All @@ -20,13 +19,13 @@ import {
WorldAttributes,
WorldListOrderBy,
} from "./types"
import { DEFAULT_WORLD_IMAGE } from "../shared/constants"
import {
buildTextsearch,
buildUpdateFavoritesQuery,
buildUpdateLikesQuery,
buildUserInteractionColumns,
buildUserInteractionJoins,
buildWorldTextSearchRank,
} from "../shared/entityInteractions"
import UserFavoriteModel from "../UserFavorite/model"
import UserLikesModel from "../UserLikes/model"
Expand All @@ -47,7 +46,7 @@ export default class WorldModel extends Model<WorldAttributes> {
static buildLatestPlaceLateralJoin(worldAlias: string): SQLStatement {
const a = SQL.raw(worldAlias)
return SQL`LEFT JOIN LATERAL (
SELECT p.contact_name, p.contact_email,
SELECT p.image, p.contact_name, p.contact_email,
p.creator_address, p.sdk, p.deployed_at
FROM places p
WHERE p.world_id = ${a}.id AND p.disabled IS FALSE
Expand Down Expand Up @@ -98,9 +97,7 @@ export default class WorldModel extends Model<WorldAttributes> {
)}
${conditional(
!!options.search,
SQL`AND ${a}.textsearch @@ to_tsquery(${tsquery(
options.search || ""
)})`
SQL`AND ${buildWorldTextSearchRank(alias, options.search || "")} > 0`
)}
${conditional(
(options.world_names?.length ?? 0) > 0,
Expand Down Expand Up @@ -178,7 +175,7 @@ export default class WorldModel extends Model<WorldAttributes> {
): SQLStatement {
const forCount = opts?.forCount ?? false
const defaultSelectColumns = SQL`w.*
, COALESCE(w.image, ${DEFAULT_WORLD_IMAGE}) as image
, COALESCE(w.image, lp.image) as image
, lp.contact_name
, '0,0' as base_position
, true as world
Expand All @@ -196,9 +193,7 @@ export default class WorldModel extends Model<WorldAttributes> {
)}
${conditional(
!forCount && !!options.search,
SQL`, ts_rank_cd(w.textsearch, to_tsquery(${tsquery(
options.search || ""
)})) as rank`
SQL`, ${buildWorldTextSearchRank("w", options.search || "")} as rank`
)}
FROM ${table(this)} w
${this.buildLatestPlaceLateralJoin("w")}
Expand Down Expand Up @@ -257,7 +252,7 @@ export default class WorldModel extends Model<WorldAttributes> {
): Promise<AggregateWorldAttributes | null> {
const sql = SQL`
SELECT w.*
, COALESCE(w.image, ${DEFAULT_WORLD_IMAGE}) as image
, COALESCE(w.image, lp.image) as image
, lp.contact_name
, '0,0' as base_position
${conditional(
Expand All @@ -279,7 +274,13 @@ export default class WorldModel extends Model<WorldAttributes> {
, 0 as user_visits
, lp.deployed_at
FROM ${table(this)} w
${this.buildLatestPlaceLateralJoin("w")}
LEFT JOIN LATERAL (
SELECT p.image, p.contact_name, p.deployed_at
FROM places p
WHERE p.world_id = w.id AND p.disabled IS FALSE
ORDER BY p.deployed_at DESC
LIMIT 1
) lp ON true
${conditional(
!!options.user,
SQL`LEFT JOIN ${table(
Expand Down Expand Up @@ -438,8 +439,6 @@ export default class WorldModel extends Model<WorldAttributes> {
highlighted: world.highlighted ?? false,
highlighted_image: world.highlighted_image ?? null,
ranking: world.ranking ?? 0,
settings_configured: world.settings_configured ?? false,
textsearch: undefined,
likes: world.likes ?? 0,
dislikes: world.dislikes ?? 0,
favorites: world.favorites ?? 0,
Expand All @@ -453,24 +452,22 @@ export default class WorldModel extends Model<WorldAttributes> {
}

/**
* Insert a world or update its deployment-derived fields if settings haven't been
* explicitly configured. Uses INSERT ... ON CONFLICT (id) DO UPDATE ... WHERE
* settings_configured = false so that user-configured worlds are never overwritten
* by deployments.
* Insert a world only if it doesn't already exist.
* Uses INSERT ... ON CONFLICT (id) DO NOTHING for atomicity.
* Returns the world ID (lowercased world_name) regardless of whether
* the insert was performed.
*/
static async insertWorldIfNotExists(
world: Partial<WorldAttributes> & { world_name: string }
): Promise<string> {
const worldData = this.buildWorldData(world)
const textsearch = this.textsearch(worldData)

const sql = SQL`
INSERT INTO ${table(this)} (
"id", "world_name", "title", "description", "image",
"content_rating", "categories", "owner", "show_in_places",
"single_player", "skybox_time", "is_private",
"highlighted", "highlighted_image", "ranking",
"settings_configured", "textsearch",
"likes", "dislikes", "favorites",
"like_rate", "like_score", "disabled", "disabled_at",
"created_at", "updated_at"
Expand All @@ -490,8 +487,6 @@ export default class WorldModel extends Model<WorldAttributes> {
${worldData.highlighted},
${worldData.highlighted_image},
${worldData.ranking},
${worldData.settings_configured},
${textsearch},
${worldData.likes},
${worldData.dislikes},
${worldData.favorites},
Expand All @@ -502,15 +497,7 @@ export default class WorldModel extends Model<WorldAttributes> {
${worldData.created_at},
${worldData.updated_at}
)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
image = EXCLUDED.image,
content_rating = EXCLUDED.content_rating,
categories = EXCLUDED.categories,
textsearch = EXCLUDED.textsearch,
updated_at = EXCLUDED.updated_at
WHERE worlds.settings_configured = false
ON CONFLICT (id) DO NOTHING
`

await this.namedQuery("insert_world_if_not_exists", sql)
Expand All @@ -520,15 +507,13 @@ export default class WorldModel extends Model<WorldAttributes> {
static async upsertWorld(
world: Partial<WorldAttributes> & { world_name: string }
): Promise<WorldAttributes> {
const worldData = this.buildWorldData({
...world,
settings_configured: true,
})
const textsearch = this.textsearch(worldData)
const worldData = this.buildWorldData(world)

// Fields that can be updated on conflict (excludes id, world_name, likes, etc.)
const updatableFields: (keyof WorldAttributes)[] = [
"title",
"description",
"image",
"content_rating",
"categories",
"owner",
Expand All @@ -538,120 +523,22 @@ export default class WorldModel extends Model<WorldAttributes> {
"is_private",
]

const setClauses: SQLStatement[] = [
SQL`"settings_configured" = true`,
SQL`"textsearch" = ${textsearch}`,
SQL`"image" = EXCLUDED."image"`,
SQL`"updated_at" = EXCLUDED."updated_at"`,
]

// Build changes object with only explicitly provided fields
// This ensures we don't overwrite existing values with defaults on conflict
const changes: Partial<WorldAttributes> = {
updated_at: worldData.updated_at,
}
for (const field of updatableFields) {
if (world[field] !== undefined) {
setClauses.push(SQL`"${SQL.raw(field)}" = EXCLUDED."${SQL.raw(field)}"`)
;(changes as Record<string, unknown>)[field] = world[field]
}
}

const sql = SQL`
INSERT INTO ${table(this)} (
"id", "world_name", "title", "description", "image",
"content_rating", "categories", "owner", "show_in_places",
"single_player", "skybox_time", "is_private",
"highlighted", "highlighted_image", "ranking",
"settings_configured", "textsearch",
"likes", "dislikes", "favorites",
"like_rate", "like_score", "disabled", "disabled_at",
"created_at", "updated_at"
) VALUES (
${worldData.id},
${worldData.world_name},
${worldData.title},
${worldData.description},
${worldData.image},
${worldData.content_rating},
${worldData.categories},
${worldData.owner},
${worldData.show_in_places},
${worldData.single_player},
${worldData.skybox_time},
${worldData.is_private},
${worldData.highlighted},
${worldData.highlighted_image},
${worldData.ranking},
${worldData.settings_configured},
${textsearch},
${worldData.likes},
${worldData.dislikes},
${worldData.favorites},
${worldData.like_rate},
${worldData.like_score},
${worldData.disabled},
${worldData.disabled_at},
${worldData.created_at},
${worldData.updated_at}
)
ON CONFLICT (id) DO UPDATE SET
${join(setClauses, SQL`, `)}
RETURNING *
`

const results = await this.namedQuery<WorldAttributes>("upsert_world", sql)
return results[0]
}

/**
* Atomically delete place records for undeployed scenes and refresh the world's
* deployment-derived fields from the next-latest remaining scene. All operations
* happen in a single SQL statement (CTE-based) to prevent inconsistent state.
*
* If settings_configured = true, the world update is skipped (user settings preserved).
* If no remaining places exist, the UPDATE produces no rows (world stays as-is but
* won't appear in queries since they check EXISTS(places)).
*/
static async deleteWorldScenesAndRefresh(
worldId: string,
basePositions: string[],
eventTimestamp: number
): Promise<void> {
const normalizedWorldId = worldId.toLowerCase()
const eventDate = new Date(eventTimestamp)

const sql = SQL`
WITH deleted AS (
DELETE FROM places
WHERE world_id = ${normalizedWorldId}
AND base_position = ANY(${basePositions})
AND deployed_at < ${eventDate}
RETURNING world_id
),
latest_remaining AS (
SELECT p.title, p.description, p.image, p.content_rating, p.categories
FROM places p
WHERE p.world_id = ${normalizedWorldId}
AND p.disabled IS FALSE
AND p.base_position != ALL(${basePositions})
ORDER BY p.deployed_at DESC
LIMIT 1
)
UPDATE worlds SET
title = lr.title,
description = lr.description,
image = lr.image,
content_rating = COALESCE(lr.content_rating, worlds.content_rating),
categories = COALESCE(lr.categories, worlds.categories),
textsearch = (
setweight(to_tsvector(coalesce(lr.title, '')), 'A') ||
setweight(to_tsvector(coalesce(worlds.world_name, '')), 'A') ||
setweight(to_tsvector(coalesce(lr.description, '')), 'B') ||
setweight(to_tsvector(coalesce(worlds.owner, '')), 'C')
),
updated_at = now()
FROM latest_remaining lr
WHERE worlds.id = ${normalizedWorldId}
AND worlds.settings_configured = false
AND EXISTS (SELECT 1 FROM deleted)
`

await this.namedQuery("delete_world_scenes_and_refresh", sql)
// Upsert on id (lowercased world_name) as the conflict target
return this.upsert(worldData, {
target: ["id"],
changes,
})
}

static async disableWorld(worldName: string): Promise<void> {
Expand Down
Loading
Loading