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
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.
Here is the result of GET
api/v1/tournaments/(REDACTED)/adjudicatorsafter 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
1096892and1095850are not reflected.Steps to Reproduce
Here's a simple python script to reproduce the bug:
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, orinstitution_conflictsof Teams)Cases where this bug might affect users: