Skip to content

API: PATCH request to add conflicts are not atomic #2783

@inohan

Description

@inohan

PATCH request to add adjudicator-adjudicator conflicts are not atomic.
When multiple PATCH requests to add conflicts to adjudicators are sent in a short span of time, some of the conflicts are discarded / not reflected.

Expected Behavior

The PATCH /api/v1/tournaments/{tournament_slug}/adjudicators/{id} endpoint is expected to append new adjudicator conflicts, while keeping old conflicts.
https://github.com/TabbycatDebate/tabbycat/blob/develop/tabbycat/api/serializers.py#L717-L731

If five PATCH requests with different content are sent to the same endpoint, all five conflicts (assuming they are unique) should be reflected.

Current Behavior

When multiple requests are sent to the same endpoint, some requests are not reflected.
Here is a log output from a short python script showing the request url and request body. The base URL is hidden.

2025-12-05 16:27:22,290 - INFO - root - [PATCH /api/v1/tournaments/experiment/adjudicators/1096891] request body=b'{"adjudicator_conflicts":["/api/v1/tournaments/experiment/adjudicators/1096892"]}'
2025-12-05 16:27:22,297 - INFO - root - [PATCH /api/v1/tournaments/experiment/adjudicators/1096891] request body=b'{"adjudicator_conflicts":["/api/v1/tournaments/experiment/adjudicators/1096893"]}'
2025-12-05 16:27:22,299 - INFO - root - [PATCH /api/v1/tournaments/experiment/adjudicators/1096891] request body=b'{"adjudicator_conflicts":["/api/v1/tournaments/experiment/adjudicators/1096894"]}'
2025-12-05 16:27:22,302 - INFO - root - [PATCH /api/v1/tournaments/experiment/adjudicators/1096891] request body=b'{"adjudicator_conflicts":["/api/v1/tournaments/experiment/adjudicators/1095849"]}'
2025-12-05 16:27:22,305 - INFO - root - [PATCH /api/v1/tournaments/experiment/adjudicators/1096891] request body=b'{"adjudicator_conflicts":["/api/v1/tournaments/experiment/adjudicators/1095850"]}'
2025-12-05 16:27:23,029 - INFO - httpx - HTTP Request: PATCH /api/v1/tournaments/experiment/adjudicators/1096891 "HTTP/1.1 200 OK"
2025-12-05 16:27:23,073 - INFO - httpx - HTTP Request: PATCH /api/v1/tournaments/experiment/adjudicators/1096891 "HTTP/1.1 200 OK"
2025-12-05 16:27:23,077 - INFO - httpx - HTTP Request: PATCH /api/v1/tournaments/experiment/adjudicators/1096891 "HTTP/1.1 200 OK"
2025-12-05 16:27:23,080 - INFO - httpx - HTTP Request: PATCH /api/v1/tournaments/experiment/adjudicators/1096891 "HTTP/1.1 200 OK"
2025-12-05 16:27:23,094 - INFO - httpx - HTTP Request: PATCH /api/v1/tournaments/experiment/adjudicators/1096891 "HTTP/1.1 200 OK"

Here is the result of GET api/v1/tournaments/(REDACTED)/adjudicators after completing these requests (only relevant parts have been extracted):

{
  "id": 1096891,
  "url": "/api/v1/tournaments/experiment/adjudicators/1096891",
  "adjudicator_conflicts": [
    "/api/v1/tournaments/experiment/adjudicators/1095849",
    "/api/v1/tournaments/experiment/adjudicators/1096893",
    "/api/v1/tournaments/experiment/adjudicators/1096894"
  ],
}

As seen, the conflicts for adjudicator id 1096892 and 1095850 are not reflected.

Steps to Reproduce

Here's a simple python script to reproduce the bug:

TABBYCAT_API_TOKEN = "YOUR API TOKEN"

# REPLACE THE LINK HERE
# ideally there should be around 5~6 adjudicators to reproduce this bug
adjudicators = [
    "https://xyz.calicotab.com/api/v1/tournaments/_/adjudicators/1",
    "https://xyz.calicotab.com/api/v1/tournaments/_/adjudicators/2",
    "https://xyz.calicotab.com/api/v1/tournaments/_/adjudicators/3",
    "https://xyz.calicotab.com/api/v1/tournaments/_/adjudicators/4",
    "https://xyz.calicotab.com/api/v1/tournaments/_/adjudicators/5",
    "https://xyz.calicotab.com/api/v1/tournaments/_/adjudicators/6"
]

import httpx
import logging
import asyncio
import json
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")

async def log_request(request: httpx.Request):
    logging.info(f"[{request.method} {request.url}] request body={request.content!r}")

async def submit_conflict(client: httpx.AsyncClient, adjudicator_from: str, adjudicator_to: str):
    response = await client.patch(
        adjudicator_from,
        json={
            "adjudicator_conflicts": [adjudicator_to]
        }
    )
    return response.json()

async def main():
    async with httpx.AsyncClient(
        event_hooks={
            "request": [log_request]
        },
        headers={
            "Authorization": f"Token {TABBYCAT_API_TOKEN}"
        },
    ) as client:

        await asyncio.gather(
            *[submit_conflict(client, adjudicators[0], adj) for adj in adjudicators[1:]]
        )
        
        await asyncio.sleep(5)
        res = await client.get(adjudicators[0])
        print(json.dumps(res.json(), indent=2))

if __name__ == "__main__":
    asyncio.run(main())

Context (Environment)

Environment: Python 3.13, but most likely to happen with any environment
This is probably applicable to other conflicts (institution_conflicts, team_conflicts, or institution_conflicts of Teams)

Cases where this bug might affect users:

  • Reflecting conflicts using API call

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions