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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions