Skip to content

Commit 1dfe0b2

Browse files
fix: Race condition protection
1 parent db6ef77 commit 1dfe0b2

File tree

6 files changed

+87
-10
lines changed

6 files changed

+87
-10
lines changed

src/entities/CheckScenes/task/handleWorldScenesUndeployment.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export async function handleWorldScenesUndeployment(
4141
)}`
4242
)
4343

44-
await PlaceModel.deleteByWorldIdAndPositions(worldName, basePositions)
44+
await PlaceModel.deleteByWorldIdAndPositions(
45+
worldName,
46+
basePositions,
47+
event.timestamp
48+
)
4549

4650
loggerExtended.log(
4751
`Deleted place records for world: ${worldName} at positions: ${basePositions.join(

src/entities/CheckScenes/task/handleWorldUndeployment.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { WorldUndeploymentEvent } from "@dcl/schemas/dist/platform/events/world"
22
import logger from "decentraland-gatsby/dist/entities/Development/logger"
33

4-
import { notifyError } from "../../Slack/utils"
54
import PlaceModel from "../../Place/model"
5+
import { notifyError } from "../../Slack/utils"
66

77
/**
88
* Handles WorldUndeploymentEvent from the worlds content server.
@@ -28,7 +28,7 @@ export async function handleWorldUndeployment(
2828
try {
2929
loggerExtended.log(`Processing world undeployment for world: ${worldName}`)
3030

31-
await PlaceModel.deleteByWorldId(worldName)
31+
await PlaceModel.deleteByWorldId(worldName, event.timestamp)
3232

3333
loggerExtended.log(`Deleted all place records for world: ${worldName}`)
3434
} catch (error: any) {

src/entities/Place/model.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,29 +470,43 @@ export default class PlaceModel extends Model<PlaceAttributes> {
470470
}
471471

472472
/**
473-
* Delete all place records associated with a world
473+
* Delete all place records associated with a world that were deployed
474+
* before the given event timestamp. This prevents stale undeployment
475+
* events from removing places that were re-deployed after the event
476+
* was emitted.
474477
*/
475-
static async deleteByWorldId(worldId: string): Promise<void> {
478+
static async deleteByWorldId(
479+
worldId: string,
480+
eventTimestamp: number
481+
): Promise<void> {
476482
const normalizedWorldId = worldId.toLowerCase()
483+
const eventDate = new Date(eventTimestamp)
477484
const sql = SQL`
478485
DELETE FROM ${table(this)}
479486
WHERE "world_id" = ${normalizedWorldId}
487+
AND "deployed_at" < ${eventDate}
480488
`
481489
await this.namedQuery("delete_by_world_id", sql)
482490
}
483491

484492
/**
485493
* Delete place records matching a world and specific base positions
494+
* that were deployed before the given event timestamp. This prevents
495+
* stale undeployment events from removing places that were re-deployed
496+
* after the event was emitted.
486497
*/
487498
static async deleteByWorldIdAndPositions(
488499
worldId: string,
489-
basePositions: string[]
500+
basePositions: string[],
501+
eventTimestamp: number
490502
): Promise<void> {
491503
const normalizedWorldId = worldId.toLowerCase()
504+
const eventDate = new Date(eventTimestamp)
492505
const sql = SQL`
493506
DELETE FROM ${table(this)}
494507
WHERE "world_id" = ${normalizedWorldId}
495508
AND "base_position" = ANY(${basePositions})
509+
AND "deployed_at" < ${eventDate}
496510
`
497511
await this.namedQuery("delete_by_world_id_and_positions", sql)
498512
}

test/fixtures/undeploymentEvent.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import {
88
* Creates a WorldUndeploymentEvent for a full world undeployment.
99
*/
1010
export function createWorldUndeploymentEvent(
11-
worldName: string
11+
worldName: string,
12+
options: { timestamp?: number } = {}
1213
): WorldUndeploymentEvent {
1314
return {
1415
type: Events.Type.WORLD,
1516
subType: Events.SubType.Worlds.WORLD_UNDEPLOYMENT,
1617
key: worldName,
17-
timestamp: Date.now(),
18+
timestamp: options.timestamp ?? Date.now(),
1819
metadata: {
1920
worldName,
2021
},
@@ -26,13 +27,14 @@ export function createWorldUndeploymentEvent(
2627
*/
2728
export function createWorldScenesUndeploymentEvent(
2829
worldName: string,
29-
scenes: Array<{ entityId: string; baseParcel: string }>
30+
scenes: Array<{ entityId: string; baseParcel: string }>,
31+
options: { timestamp?: number } = {}
3032
): WorldScenesUndeploymentEvent {
3133
return {
3234
type: Events.Type.WORLD,
3335
subType: Events.SubType.Worlds.WORLD_SCENES_UNDEPLOYMENT,
3436
key: worldName,
35-
timestamp: Date.now(),
37+
timestamp: options.timestamp ?? Date.now(),
3638
metadata: {
3739
worldName,
3840
scenes,

test/integration/handleWorldScenesUndeployment.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,38 @@ describe("when handling the WorldScenesUndeploymentEvent", () => {
270270
})
271271
})
272272

273+
describe("and the WorldScenesUndeploymentEvent has a timestamp older than the deployment", () => {
274+
const worldName = "racecondition.dcl.eth"
275+
276+
beforeEach(async () => {
277+
await deployWorldScene({
278+
worldName,
279+
title: "Re-deployed Scene",
280+
base: "10,10",
281+
parcels: ["10,10"],
282+
})
283+
})
284+
285+
it("should not delete the place record", async () => {
286+
// Simulate a stale undeployment event with a timestamp before the deployment
287+
const staleTimestamp = Date.now() - 60_000
288+
const event = createWorldScenesUndeploymentEvent(
289+
worldName,
290+
[{ entityId: "entity-stale", baseParcel: "10,10" }],
291+
{ timestamp: staleTimestamp }
292+
)
293+
await handleWorldScenesUndeployment(event)
294+
295+
const response = await supertest(app)
296+
.get("/api/places")
297+
.query({ names: worldName })
298+
.expect(200)
299+
300+
expect(response.body.data).toHaveLength(1)
301+
expect(response.body.data[0].title).toBe("Re-deployed Scene")
302+
})
303+
})
304+
273305
describe("and the WorldScenesUndeploymentEvent has an empty scenes array", () => {
274306
it("should return early without errors", async () => {
275307
const event = createWorldScenesUndeploymentEvent("anyworld.dcl.eth", [])

test/integration/handleWorldUndeployment.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,31 @@ describe("when handling the WorldUndeploymentEvent", () => {
167167
})
168168
})
169169

170+
describe("and the WorldUndeploymentEvent has a timestamp older than the deployment", () => {
171+
const worldName = "racecondition.dcl.eth"
172+
173+
beforeEach(async () => {
174+
await deployWorldScene({ worldName, title: "Re-deployed Scene" })
175+
})
176+
177+
it("should not delete the place record", async () => {
178+
// Simulate a stale undeployment event with a timestamp before the deployment
179+
const staleTimestamp = Date.now() - 60_000
180+
const event = createWorldUndeploymentEvent(worldName, {
181+
timestamp: staleTimestamp,
182+
})
183+
await handleWorldUndeployment(event)
184+
185+
const response = await supertest(app)
186+
.get("/api/places")
187+
.query({ names: worldName })
188+
.expect(200)
189+
190+
expect(response.body.data).toHaveLength(1)
191+
expect(response.body.data[0].title).toBe("Re-deployed Scene")
192+
})
193+
})
194+
170195
describe("and the WorldUndeploymentEvent has an empty world name", () => {
171196
it("should return early without errors", async () => {
172197
const event = createWorldUndeploymentEvent("")

0 commit comments

Comments
 (0)