Concurrent project saves can create topics that differ only in case

Summary

Projects::TopicList#topics looks each name up in the organization case-insensitively and creates the topic when there is none, with first_or_create (https://gitlab.com/gitlab-org/gitlab/-/blob/0cbe3abd6db5ce784dfd92b60d3f4e0dd0b23faf/app/models/projects/topic_list.rb#L21-28) or, after !266664 (merged), a first and a create. The lookup and the insert are not atomic, and the unique indexes on topics are on (organization_id, name) and (organization_id, slug), both case-sensitive. This predates that MR, which moved the code into Projects::TopicList.

Steps to reproduce

  1. Take an organization with no topic named foo in any case.
  2. Save two of its projects at the same time, one with the topic list Foo and the other with foo, for example with two concurrent PUT /projects/:id requests.

Example Project

None: it is a race between two saves, which cannot be shown on GitLab.com without writing to it.

What is the current bug behavior?

Both saves can find no topic and create one each, so the organization gets Foo and foo.

What is the expected correct behavior?

One topic, assigned to both projects.

Possible fixes

A unique index on (organization_id, lower(name)), once any case duplicates are merged, so that the second insert fails and the save can look the topic up again.

A database reviewer raised this in !266664 (comment 4177899783).

Edited by 🤖 GitLab Bot 🤖