You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
shape_index_migrate_after_delete (added in #9002) carries a keys array's slot index onto the private clone that js_object_delete_field makes, by moving it:
The source array on that path is expected to be shared. The clone exists precisely because it is — delete_rest.rs:335:
CRITICAL: clone the keys_array before mutating it. The same keys_array is shared across all objects that built the same shape via transition_cache_lookup-hit fast paths.
So a delete from any one object strands every sibling that still shares the source array: their accelerator disappears and each rebuilds it (decode + FNV-hash every key name) on its next lookup.
The neighbouring shape_keys_grown documents exactly this rule for itself:
Callers must pass the OWNED-grow pair only: a shared array's fork is a genuine transition (the clone starts a NEW identity and the old address still describes the siblings' live shape — migrating it would corrupt them).
Not a correctness bug
Verified in the code, not just from the doc: shape_slot_lookup re-validates the stored key bytes against the requested bytes (stored == key_bytes) before returning a slot, and drops the index outright when indexed_len > key_count. A stranded or stale index can only produce a MISS and the caller's own fallback — never a wrong property. This is purely a throughput hazard.
bench_populated_delete.ts deletes from a single object, so there are no siblings to strand. The regression needs N objects sharing one shape where one of them deletes — at which point the other N−1 pay a full rebuild per delete, which is the very cost #9002 removes for the deleter.
Suggested fix
Branch on the source's GC_FLAG_SHAPE_SHARED bit (idiom already in field_set_by_name/tail.rs:950): move the index when the source is owned and about to die, clone it when the source is shared so siblings keep theirs. SlotList already derives Clone; ShapeIndex would need it. Cloning the table is one allocation plus a memcpy — far below the rebuild it avoids — but it is a real cost on the deleting path, so it wants its own A/B rather than being folded into #9002's numbers after the fact.
#9005's description corrects #9002: the migrated index was being discarded immediately by the shape_drop in the delete tail, so #9002's measured win came from pruning the old entry — which is precisely the remove(&old_keys_id) this issue is about. That makes the question here load-bearing rather than incidental: the measured gain is partly the removal of an entry that is only garbage if the source array was owned.
The safety net is weaker than the comments suggest. "A wrong index can only MISS" is true for keys_find_slot_by_bytes, which has a linear-scan backstop (keys_lookup.rs:90). It is not true on the set path: field_set_by_name/tail.rs:720-723 treats a miss as authoritative and skips the scan to append unverified. A false miss there duplicates a key rather than falling back. That does not change this issue's severity — a stranded sibling gets no index and rebuilds correctly, it does not get a wrong one — but it means the net should not be leaned on when reasoning about future changes here.
perf(runtime): keep the migrated key index instead of dropping it (populated delete −36.2%) #9005's own "Still on the table" section names the same lever: GC_FLAG_SHAPE_SHARED is stamped when the transition cache publishes an array, and object_ops::keys_array already treats it as authoritative. Branching on it would let an owned array be compacted in place and would answer this issue — move the index when the source is owned and dying, clone it when shared.
Activity
proggeramlug commentedon Aug 29, 2026
Context sharpened by #9005, now merged.
#9005's description corrects #9002: the migrated index was being discarded immediately by the
shape_dropin the delete tail, so #9002's measured win came from pruning the old entry — which is precisely theremove(&old_keys_id)this issue is about. That makes the question here load-bearing rather than incidental: the measured gain is partly the removal of an entry that is only garbage if the source array was owned.Two further notes from auditing #9005:
The safety net is weaker than the comments suggest. "A wrong index can only MISS" is true for
keys_find_slot_by_bytes, which has a linear-scan backstop (keys_lookup.rs:90). It is not true on the set path:field_set_by_name/tail.rs:720-723treats a miss as authoritative and skips the scan to append unverified. A false miss there duplicates a key rather than falling back. That does not change this issue's severity — a stranded sibling gets no index and rebuilds correctly, it does not get a wrong one — but it means the net should not be leaned on when reasoning about future changes here.perf(runtime): keep the migrated key index instead of dropping it (populated delete −36.2%) #9005's own "Still on the table" section names the same lever:
GC_FLAG_SHAPE_SHAREDis stamped when the transition cache publishes an array, andobject_ops::keys_arrayalready treats it as authoritative. Branching on it would let an owned array be compacted in place and would answer this issue — move the index when the source is owned and dying, clone it when shared.perf(runtime): preserve shared shape index across delete
perf(runtime): preserve shared shape index across delete (#9067)