Skip to content

Fix racy tag count updates under concurrent tagging - #1329

Merged
tomchop merged 3 commits into
mainfrom
fix/tag-count-atomic
Jul 28, 2026
Merged

Fix racy tag count updates under concurrent tagging#1329
tomchop merged 3 commits into
mainfrom
fix/tag-count-atomic

Conversation

@tomchop

@tomchop tomchop commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Tag.count was updated via a full-document read-modify-write
(self.count += delta; self.save()), which loses updates under
concurrent tagging: two callers tagging/untagging with the same tag at
the same time both read the same stale count, increment it locally,
and save it back — one increment is silently dropped instead of
raising a conflict.

This adds Tag.increment_count(), which adjusts the count via a
single self-contained AQL UPDATE, reusing the conflict-retry helper
(execute_aql_with_conflict_retry) introduced in #1328 for the same
class of bug in link_to()/link_to_acl(). The read and write happen
in one server-side statement instead of racing across two client round
trips.

increment_count() also republishes the same ObjectEvent that
Tag.save() used to publish on every count change — bypassing
save() would otherwise silently drop that event, which the existing
test_publish_tag_event regression test in tests/core_tests/events.py
caught during verification.

YetiTagModel.tag() now calls increment_count() instead of the racy
count += 1 / count -= 1 + save() pattern, for both the tag-add
and tag-removal (on clear=True) paths.

Depends on

Stacked on #1328 (fix/link-to-atomic-upsert) — reuses
execute_aql_with_conflict_retry, which that PR introduces. Base
branch here is fix/link-to-atomic-upsert, not main; this should be
retargeted to main once #1328 merges (or merged after it).

Out of scope: a separate tag-creation race

While verifying this fix, I found a different, pre-existing race in
tag creation (not count updates): concurrent Tag(name=...).save()
calls for a brand-new tag name can hit the unique index on tags.name
with an ArangoDB write-write conflict ([HTTP 409][ERR 1200]) rather
than one caller finding the other's newly-created row. This is
timing-dependent and reproduces intermittently. It's a different
mechanism than the count race fixed here (it's about the initial
insert racing another initial insert, not a read-modify-write on an
existing document), so I've left it out of this PR as a follow-up.

Test plan

  • Added test_concurrent_tag_same_tag_count_is_atomic to
    tests/schemas/tag.py: pre-creates the tag (to isolate from the
    creation race above), then tags 20 different objects with it
    concurrently via ThreadPoolExecutor, asserting the final count
    is exactly 20.
  • Verified the new test fails on pre-fix code (6 != 20) and
    passes with the fix.
  • Full tests/schemas suite: 190/190 pass.
  • Full tests/apiv2 suite: 198/200 pass (2 known pre-existing
    failures in tests/apiv2/tasks.py, unrelated to this change).
  • Full tests/core_tests suite: 29/29 pass, including
    test_publish_tag_event, which caught the event-publishing
    regression during development (see above) and now passes.
  • ty check (core+yetictl and plugins jobs): 0 errors.
  • ruff check / ruff format --check: clean.

tomchop added 2 commits July 27, 2026 18:08
Both methods do check-then-act: query whether an edge already exists between
(source, target[, type]), then either update it or create a new one. Under
concurrent calls for the same pair (e.g. two feed workers linking the same
observable/entity, or rbac.set_acls() called concurrently), two callers can
both see "no edge yet" and each create one -- producing duplicate edges
instead of one edge with an accurate count, plus the count updates on top of
that race independently (lost updates on the read-modify-write .count += 1).

Reproduced empirically before this fix: 20 concurrent link_to() calls for the
same pair produced 3 duplicate edges whose counts summed to less than 20 (a
second, compounding race on top of the duplicate-edge one).

Fix: replace the check-then-act with a single atomic ArangoDB AQL UPSERT per
call (match on _from/_to[/type], INSERT if absent, UPDATE count/description/
modified if present). ArangoDB evaluates a single-document UPSERT atomically
server-side -- there's no client-side read to go stale -- but two genuinely
concurrent writers to the *same* document can still get ArangoDB's own
write-write conflict rejection (error 1200) rather than a silent lost update;
added a small retry helper for that case (verified: without retry, 50
concurrent increments on one counter reproducibly hit this; with retry, they
don't).

Both methods keep their exact external contract: same return type/shape
(Relationship/RoleRelationship, loaded via .load() as before), same
LinkEvent semantics (EventType.new vs .update, derived from whether the
UPSERT's OLD pseudo-variable is null -- verified this is exactly null on
insert, the previous document on update), same collection-name event-skip
guard. The pre-existing "new" branch already used col.link() through the
async-execution context; the replacement uses a direct sync AQL call
(matching what the existing existence-check query in the same method
already did) -- this doesn't touch or resolve the separate, currently-paused
question of de-asyncing the rest of the connector.

Added concurrency regression tests (tests/schemas/graph.py,
tests/schemas/rbac.py): 20 concurrent callers linking the same pair now
collapse to exactly one edge with the correct count/role. Verified both fail
against the pre-fix code (2 edges each) and pass against the fix.

Found and deliberately left out of scope: deleting a RoleRelationship object
always fails to publish its deletion event (core/events/message.py's
YetiObjectTypes discriminated union has no "acl" branch for it, only
"relationship" for the plain Relationship) -- pre-existing, unrelated to this
change (link_to_acl() itself never published events even before this fix),
silently swallowed by delete()'s broad except. Worth a follow-up.

Verified: both ty jobs 0 errors; ruff check + format clean; tests/schemas
189/189 (full suite, incl. the 2 new tests), tests/core_tests 29/29,
tests/apiv2 198/200 (2 pre-existing tasks.py failures, confirmed unrelated
across every PR in this batch).

From the backend architecture review, item #7 (transactions/racy
denormalized counters) -- the two spots it named (tag() and link_to()) as
having user-visible atomicity gaps. tag()'s count race is a separate,
smaller follow-up (core/schemas/model.py, not the connector).
Tag.count was updated via a full-document read-modify-write
(self.count += delta; self.save()), which loses updates when two
callers tag/untag with the same tag concurrently: both read the same
stale count, increment it locally, and save it back, so one increment
is silently dropped instead of raising a conflict.

Add Tag.increment_count(), which adjusts the count via a single
self-contained AQL UPDATE (reusing the conflict-retry helper added in
the link_to() atomicity fix), so the read and write happen in one
server-side statement instead of racing across two round trips. It
also republishes the same ObjectEvent that Tag.save() used to publish
on every count change, since bypassing save() would otherwise silently
drop that event.

YetiTagModel.tag() now calls increment_count() instead of the racy
count += 1 / count -= 1 + save() pattern.

Verified via a concurrency regression test (20 objects tagging the
same tag concurrently) that fails on the old code (undercounts) and
passes with the fix.

Note: Tag creation itself has a separate, pre-existing race --
concurrent Tag(name=...).save() calls for a brand new tag name can hit
the unique index on tags.name with a write-write conflict rather than
one of them finding the other's row. Out of scope here; left as a
follow-up.
Base automatically changed from fix/link-to-atomic-upsert to main July 28, 2026 07:25
@tomchop
tomchop merged commit d4ff304 into main Jul 28, 2026
5 checks passed
@tomchop
tomchop deleted the fix/tag-count-atomic branch July 28, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant