Prune on cascaded deletion

This commit is contained in:
Ukendio 2026-02-19 20:20:48 +01:00
parent aeedea2fcb
commit 4236bd02fd
2 changed files with 121 additions and 35 deletions

View file

@ -533,7 +533,11 @@ end
local function entity_index_get_alive(entity_index: entityindex, entity: i53): i53?
local r = entity_index_try_get_any(entity_index, entity :: number)
if r then
return entity_index.dense_array[r.dense]
local dense = r.dense
if dense > entity_index.alive_count then
return nil
end
return entity_index.dense_array[dense]
end
return nil
end
@ -3510,12 +3514,17 @@ local function world_new(DEBUG: boolean?)
end
end
end
if idr_t then
local archetype_ids = idr_t.records
local to_remove = {}:: { [i53]: componentrecord}
local did_cascade_delete = false
for archetype_id in archetype_ids do
local idr_t_archetype = archetypes[archetype_id]
if not idr_t_archetype then
continue
end
local idr_t_types = idr_t_archetype.types
local entities = idr_t_archetype.entities
local deleted_any = false
@ -3547,6 +3556,7 @@ local function world_new(DEBUG: boolean?)
end
if deleted_any then
did_cascade_delete = true
continue
end
@ -3583,9 +3593,6 @@ local function world_new(DEBUG: boolean?)
for id, component_record in to_remove do
local on_remove = component_record.on_remove
if on_remove then
-- NOTE(marcus): We could be smarter with this and
-- assume hooks are deterministic and that they will
-- move to the same archetype. However users often are not reasonable people.
on_remove(child, id)
local src = r.archetype
if src ~= idr_t_archetype then
@ -3601,6 +3608,20 @@ local function world_new(DEBUG: boolean?)
table.clear(to_remove)
archetype_destroy(world, idr_t_archetype)
end
if did_cascade_delete then
for archetype_id in archetype_ids do
local idr_t_archetype = archetypes[archetype_id]
if not idr_t_archetype then
continue
end
local entities = idr_t_archetype.entities
for i = #entities, 1, -1 do
world_delete(world, entities[i])
end
archetype_destroy(world, idr_t_archetype)
end
end
end
if idr_r then

View file

@ -653,6 +653,71 @@ TEST("world:children()", function()
jecs.ECS_META_RESET()
end)
-- Arauser repro: many parents, only some have routes+checkpoints; delete all parents.
-- With the bug: 1 checkpoint can remain and/or world:target returns non-alive parent.
-- Scale and structure match arauser so the test FAILS when the bug is present.
TEST("ChildOf cascade: world_target must not return non-alive parent", function()
local w = jecs.world(true)
local ParentTag = w:component()
local Anything = w:component()
local RouteTag = w:entity()
local CheckpointTag = w:entity()
local RouteOf = w:entity()
-- Like arauser: many parents (tiles) with two components, only a subset get routes + checkpoints
local nParents = 2000
local nWithChildren = 10
local parents = {}
for i = 1, nParents do
local p = w:entity()
w:set(p, ParentTag, {
row = math.floor((i - 1) / 50) + 1,
col = ((i - 1) % 50) + 1,
})
w:add(p, Anything)
parents[i] = p
end
local used = {}
local picked = 0
while picked < nWithChildren do
local idx = math.random(1, nParents)
if not used[idx] then
used[idx] = true
picked += 1
local p = parents[idx]
local route = w:entity()
w:add(route, RouteTag)
w:add(route, pair(ChildOf, p))
local checkpoint = w:entity()
w:add(checkpoint, CheckpointTag)
w:add(checkpoint, pair(ChildOf, p))
w:add(checkpoint, pair(RouteOf, route))
end
end
-- Delete all parents (collect first to avoid iterator invalidation)
local toDelete = {}
for e in w:query(ParentTag):iter() do
toDelete[#toDelete + 1] = e
end
for _, e in ipairs(toDelete) do
w:delete(e)
end
-- These must hold; with the bug one of them fails (checkpoint remains or parent not alive)
local count = 0
for checkpoint in w:query(CheckpointTag):iter() do
count += 1
CHECK(w:contains(checkpoint))
local parent = w:target(checkpoint, ChildOf)
if parent ~= nil then
CHECK(w:contains(parent))
end
end
CHECK(count == 0)
jecs.ECS_META_RESET()
end)
-- TEST("world:purge()", function()
-- do CASE "should remove all instances of specified component"
-- local world = jecs.world()