⚠️ 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
- Inspect
lib/private/SystemTag/SystemTagObjectMapper.php
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
Bug description
Description
OC\SystemTag\SystemTagObjectMapper::setObjectIdsForTag()dispatches the legacyMapperEvent::EVENT_UNASSIGNevent for every object in$removedObjectIdstwice: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
$removedObjectIdsarray and dispatch the exact same event, with no behaviordifference between them.
Steps to reproduce
Steps to reproduce
lib/private/SystemTag/SystemTagObjectMapper.phpgrep -n "removedObjectIds as" SystemTagObjectMapper.phpreturns two matchesinside
setObjectIdsForTag():"// 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.phpwrites an Activity feed entry percall). 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):
dispatch loop alone (measured twice, matching the duplication)
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 andcompute 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 alreadycarries 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