Skip to content

[Bug]: setObjectIdsForTag() dispatches removed-object events twice, doubling cost for large removed-sets #62631

Description

@deividmodesto

⚠️ This issue respects the following points: ⚠️

Bug description

Description

OC\SystemTag\SystemTagObjectMapper::setObjectIdsForTag() dispatches the legacy
MapperEvent::EVENT_UNASSIGN event for every object in $removedObjectIds twice:
once right after the DELETE commits, and again at the very end of the method.

This is a straightforward code duplication bug — both loops iterate the exact same
$removedObjectIds array and dispatch the exact same event, with no behavior
difference between them.

Steps to reproduce

Steps to reproduce

  1. Inspect lib/private/SystemTag/SystemTagObjectMapper.php
  2. grep -n "removedObjectIds as" SystemTagObjectMapper.php returns two matches
    inside setObjectIdsForTag():
    • one right after the DELETE transaction commits
    • one at the very end of the method, under the comment
      "// Dispatch unassign events for removed object ids"

Expected behavior

Impact

Each dispatch(MapperEvent::EVENT_UNASSIGN, ...) call triggers real listener work
(e.g. apps/systemtags/lib/Activity/Listener.php writes an Activity feed entry per
call). For a tag with a large removed-set (e.g. thousands of objects — which happens
whenever a client submits a "set object ids for tag" PROPPATCH with a smaller/stale
list against a tag that currently has many more tagged objects, a completely normal
occurrence via the systemtags WebDAV collection endpoint), this doubles the method's
execution time.

Measured directly on a production instance (Nextcloud 34.0.1, MariaDB 10.6) using a
disposable test tag populated with 11,932 real object mappings, then submitting a
tiny 3-item object list (simulating a stale client view):

  • With both loops present: ~37.7s total, ~18.3s of which was the removed-event
    dispatch loop alone (measured twice, matching the duplication)
  • After removing the duplicate loop: ~18.8s total

Because the DELETE already commits early in the method (before either event-dispatch
loop runs), if the request is interrupted during this now-halved-but-still-long
window (execution timeout, worker recycling, etc.), the tag ends up with zero
mappings and no reinsert ever attempted — i.e. this contributes directly to
data-loss-shaped failures for tags with many objects, not just a performance
inconvenience.

We've also observed evidence suggesting a related concurrency race: because the
DELETE and the subsequent INSERT happen in two separate transactions with a real
gap between them (now ~18s even after removing the duplicate dispatch), a second
concurrent caller's initial getObjectIdsForTags() read can land in that gap and
compute its diff against a transiently near-empty table, compounding the problem
under concurrent usage of the same tag.

Suggested fix

Remove the second (redundant) loop at the end of setObjectIdsForTag(). Ideally,
also consider replacing the per-object dispatch() calls entirely with the already-
present batched dispatchTyped(new TagUnassignedEvent(...)) call, since that already
carries the full object list in one event and listeners could be updated to use it
instead of the legacy per-object event — this would remove the O(n) per-object
listener cost entirely rather than just halving it. Separately, wrapping the DELETE
and INSERT in a single transaction (with appropriate row locking) would close the
concurrency race described above.

Environment

  • Nextcloud 34.0.1.2
  • PHP 8.3
  • MariaDB 10.6.22

Metadata

Metadata

Assignees

No one assigned

    Labels

    0. Needs triagePending check for reproducibility or if it fits our roadmap32-feedbackbug

    Type

    Projects

    Status
    To triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions