Skip to content

Commit 3936da4

Browse files
committed
Return new seqno from reaction-modifying endpoints
This allows Session to properly avoid a race condition between adding a reaction and getting a reaction update without the reaction applied yet (e.g. because the poll result was generated before the reaction update). With the seqno returned, clients can use it to detect such a case to properly handle local display of not-yet-updated reactions applied to a post.
1 parent 6560229 commit 3936da4

File tree

3 files changed

+119
-66
lines changed

3 files changed

+119
-66
lines changed

sogs/model/room.py

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,23 +1154,34 @@ def add_reaction(self, user: User, msg_id: int, reaction: str):
11541154
The post must exist in the room (throws NoSuchPost if it does not).
11551155
11561156
The user must have read permission in the room (throws BadPermission if not).
1157+
1158+
Returns a tuple of: bool indicating whether adding was successful (False = reaction already
1159+
present), and the new message seqno value.
11571160
"""
11581161

11591162
self._check_reaction_request(user, msg_id, reaction)
11601163

1161-
try:
1162-
query(
1163-
"""
1164-
INSERT INTO message_reactions (message, reaction, "user")
1165-
VALUES (:msg, :react, :user)
1166-
""",
1167-
msg=msg_id,
1168-
react=reaction,
1169-
user=user.id,
1170-
)
1171-
except sqlalchemy.exc.IntegrityError:
1172-
return False
1173-
return True
1164+
with db.transaction():
1165+
try:
1166+
query(
1167+
"""
1168+
INSERT INTO message_reactions (message, reaction, "user")
1169+
VALUES (:msg, :react, :user)
1170+
""",
1171+
msg=msg_id,
1172+
react=reaction,
1173+
user=user.id,
1174+
)
1175+
added = True
1176+
seqno = query("SELECT seqno FROM messages WHERE id = :msg", msg=msg_id).first()[0]
1177+
1178+
except sqlalchemy.exc.IntegrityError:
1179+
added = False
1180+
1181+
if not added:
1182+
seqno = query("SELECT seqno FROM messages WHERE id = :msg", msg=msg_id).first()[0]
1183+
1184+
return added, seqno
11741185

11751186
def delete_reaction(self, user: User, msg_id: int, reaction: str):
11761187
"""
@@ -1181,19 +1192,23 @@ def delete_reaction(self, user: User, msg_id: int, reaction: str):
11811192
"""
11821193

11831194
self._check_reaction_request(user, msg_id, reaction)
1184-
return (
1185-
query(
1186-
"""
1187-
DELETE FROM user_reactions
1188-
WHERE reaction = (SELECT id FROM reactions WHERE message = :m AND reaction = :r)
1189-
AND "user" = :u
1190-
""",
1191-
m=msg_id,
1192-
r=reaction,
1193-
u=user.id,
1194-
).rowcount
1195-
> 0
1196-
)
1195+
with db.transaction():
1196+
removed = (
1197+
query(
1198+
"""
1199+
DELETE FROM user_reactions
1200+
WHERE reaction = (SELECT id FROM reactions WHERE message = :m AND reaction = :r)
1201+
AND "user" = :u
1202+
""",
1203+
m=msg_id,
1204+
r=reaction,
1205+
u=user.id,
1206+
).rowcount
1207+
> 0
1208+
)
1209+
seqno = query("SELECT seqno FROM messages WHERE id = :msg", msg=msg_id).first()[0]
1210+
1211+
return removed, seqno
11971212

11981213
def delete_all_reactions(self, mod: User, msg_id: int, reaction: Optional[str] = None):
11991214
"""
@@ -1215,16 +1230,20 @@ def delete_all_reactions(self, mod: User, msg_id: int, reaction: Optional[str] =
12151230

12161231
self._check_reaction_request(mod, msg_id, reaction, allow_none=True, mod_required=True)
12171232

1218-
return query(
1219-
f"""
1220-
DELETE FROM user_reactions WHERE
1221-
reaction IN (SELECT id FROM reactions WHERE
1222-
message = :m
1223-
{'AND reaction = :r' if reaction is not None else ''})
1224-
""",
1225-
m=msg_id,
1226-
r=reaction,
1227-
).rowcount
1233+
with db.transaction():
1234+
removed = query(
1235+
f"""
1236+
DELETE FROM user_reactions WHERE
1237+
reaction IN (SELECT id FROM reactions WHERE
1238+
message = :m
1239+
{'AND reaction = :r' if reaction is not None else ''})
1240+
""",
1241+
m=msg_id,
1242+
r=reaction,
1243+
).rowcount
1244+
seqno = query("SELECT seqno FROM messages WHERE id = :msg", msg=msg_id).first()[0]
1245+
1246+
return removed, seqno
12281247

12291248
def get_reactors(
12301249
self,

sogs/routes/messages.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,10 @@ def message_react(room, msg_id, reaction):
572572
573573
- `"added"` — boolean value indicating whether the reaction was added (true) or already present
574574
(false).
575+
- `"seqno"` — the message's new seqno value. This can be used to identify stale reaction
576+
updates when polling and reactions can race: if an in-progress poll returns a reaction update
577+
for the message with a seqno less than this value then the client can know that that reaction
578+
update won't yet have the reaction added here.
575579
576580
# Error status codes
577581
@@ -583,8 +587,8 @@ def message_react(room, msg_id, reaction):
583587
(instead in such a case the success response return value includes `"added": false`).
584588
"""
585589

586-
added = room.add_reaction(g.user, msg_id, reaction)
587-
return jsonify({"added": added})
590+
added, seqno = room.add_reaction(g.user, msg_id, reaction)
591+
return jsonify({"added": added, "seqno": seqno})
588592

589593

590594
@messages.delete("/room/<Room:room>/reaction/<int:msg_id>/<path:reaction>")
@@ -608,6 +612,7 @@ def message_unreact(room, msg_id, reaction):
608612
609613
- `"removed"` — boolean value indicating whether the reaction was removed (true) or was not
610614
present to begin with (false).
615+
- `"seqno"` — the message's new seqno value. (See description in the put reaction endpoint).
611616
612617
# Error status codes
613618
@@ -618,8 +623,8 @@ def message_unreact(room, msg_id, reaction):
618623
Note that it is *not* an error to attempt to remove a reaction that does not exist (instead in
619624
such a case the success response return value includes `"removed": false`).
620625
"""
621-
removed = room.delete_reaction(g.user, msg_id, reaction)
622-
return jsonify({"removed": removed})
626+
removed, seqno = room.delete_reaction(g.user, msg_id, reaction)
627+
return jsonify({"removed": removed, "seqno": seqno})
623628

624629

625630
@messages.delete("/room/<Room:room>/reactions/<int:msg_id>/<path:reaction>")
@@ -643,18 +648,19 @@ def message_delete_reactions(room, msg_id, reaction=None):
643648
644649
# Return value
645650
646-
On success returns a 200 status code and a JSON object response body with key:
651+
On success returns a 200 status code and a JSON object response body with keys:
647652
648653
- `"removed"` — the total number of reactions that were deleted.
654+
- `"seqno"` — the message's new seqno value. (See description in the put reaction endpoint).
649655
650656
# Error status codes
651657
652658
- 403 Forbidden — if not a moderator
653659
- 404 Not Found — if the referenced post does not exist or is not a regular message
654660
- 400 Bad Request — if the input does not contain a valid reaction *or* `"all": true`.
655661
"""
656-
removed = room.delete_all_reactions(g.user, msg_id, reaction)
657-
return jsonify({"removed": removed})
662+
removed, seqno = room.delete_all_reactions(g.user, msg_id, reaction)
663+
return jsonify({"removed": removed, "seqno": seqno})
658664

659665

660666
@messages.get("/room/<Room:room>/reactors/<int:msg_id>/<path:reaction>")

tests/test_reactions.py

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ def test_reactions(client, room, room2, user, user2, mod, admin, global_mod, glo
1818

1919
seqno = r.json[-1]["seqno"]
2020

21+
new_seqno = seqno
2122
for x in ("🖕", "🍆", "f", "y/n", "abcdefghijkl"):
2223
r = sogs_put(client, f"/room/test-room/reaction/4/{x}", {}, user)
2324
assert r.status_code == 200
24-
assert r.json["added"]
25+
new_seqno += 1
26+
assert r.json == {"added": True, "seqno": new_seqno}
2527

2628
# Without the ?t=r flag, we don't get reaction-only updates:
2729
r = sogs_get(client, f"/room/test-room/messages/since/{seqno}", user2)
@@ -37,7 +39,7 @@ def test_reactions(client, room, room2, user, user2, mod, admin, global_mod, glo
3739
# Already present:
3840
r = sogs_put(client, "/room/test-room/reaction/4/🖕", {}, user)
3941
assert r.status_code == 200
40-
assert not r.json["added"]
42+
assert r.json == {"added": False, "seqno": seqno}
4143
assert sogs_get(client, f"/room/test-room/messages/since/{seqno}?t=r", user2).json == []
4244

4345
r = sogs_get(client, "/room/test-room/messages/since/0?t=r", user2)
@@ -46,14 +48,15 @@ def test_reactions(client, room, room2, user, user2, mod, admin, global_mod, glo
4648
assert r.json[-1]["seqno"] == seqno
4749

4850
r = sogs_put(client, "/room/test-room/reaction/10/🍍", {}, user)
49-
assert r.json["added"]
51+
assert r.json == {"added": True, "seqno": seqno + 1}
5052
r = sogs_put(client, "/room/test-room/reaction/4/🖕", {}, user2)
51-
assert r.json["added"]
53+
assert r.json == {"added": True, "seqno": seqno + 2}
5254
r = sogs_put(client, "/room/test-room/reaction/4/🍍", {}, user)
53-
assert r.json["added"]
55+
assert r.json == {"added": True, "seqno": seqno + 3}
5456

5557
r = sogs_get(client, f"/room/test-room/messages/since/{seqno}?t=r", user2)
5658
assert {x['id']: x['seqno'] for x in r.json} == {4: seqno + 3, 10: seqno + 1}
59+
seqno_10 = seqno + 1
5760
seqno += 3
5861

5962
r = sogs_get(client, "/room/room2/messages/since/0?t=r", user2)
@@ -62,12 +65,17 @@ def test_reactions(client, room, room2, user, user2, mod, admin, global_mod, glo
6265
# If there is both an edit and new reactions, we should get the full message including reactions
6366
# and *not* a separate reactions row.
6467
room.edit_post(mod, 4, data=b'edited fake data 4', sig=pad64(b'fake sig 4b'))
68+
new_seqno = seqno + 1
6569
for u in (user2, global_admin, mod, global_mod, admin):
6670
r = sogs_put(client, "/room/test-room/reaction/4/🍍", {}, u)
67-
assert r.json['added']
68-
assert not sogs_put(client, "/room/test-room/reaction/4/🍍", {}, user).json['added']
71+
new_seqno += 1
72+
assert r.json == {'added': True, 'seqno': new_seqno}
73+
assert sogs_put(client, "/room/test-room/reaction/4/🍍", {}, user).json == {
74+
'added': False,
75+
"seqno": new_seqno,
76+
}
6977
r = sogs_put(client, "/room/test-room/reaction/4/🦒🦍🐍🐊🦢🦁🦎", {}, user)
70-
assert r.json['added']
78+
assert r.json == {'added': True, "seqno": new_seqno + 1}
7179

7280
exp_reactions = {
7381
'abcdefghijkl': {'index': 4, 'count': 1, 'reactors': [user.session_id]},
@@ -147,9 +155,8 @@ def test_reactions(client, room, room2, user, user2, mod, admin, global_mod, glo
147155

148156
r = sogs_delete(client, "/room/test-room/reactions/4/🍍", global_admin)
149157
assert r.status_code == 200
150-
n_pineapples = exp_reactions["🍍"]["count"]
151-
assert r.json["removed"] == n_pineapples
152-
assert r.json["removed"] == 5
158+
assert exp_reactions["🍍"]["count"] == 5
159+
assert r.json == {"removed": 5, "seqno": seqno + 5}
153160
del exp_reactions["🍍"]
154161
exp_reactions["🦒🦍🐍🐊🦢🦁🦎"]["index"] -= 1
155162

@@ -160,7 +167,7 @@ def test_reactions(client, room, room2, user, user2, mod, admin, global_mod, glo
160167
n_other = sum(x["count"] for x in exp_reactions.values())
161168
r = sogs_delete(client, "/room/test-room/reactions/4", mod)
162169
assert r.status_code == 200
163-
assert r.json["removed"] == n_other
170+
assert r.json == {"removed": n_other, "seqno": seqno + n_other}
164171

165172
r = sogs_get(client, f"/room/test-room/messages/since/{seqno}?t=r&reactors=0", user2)
166173
assert r.json == [{'id': 4, 'reactions': {}, 'seqno': seqno + n_other}]
@@ -172,11 +179,23 @@ def test_reactions(client, room, room2, user, user2, mod, admin, global_mod, glo
172179
assert [x['id'] for x in r if x['reactions']] == [10]
173180
assert r[7]['reactions'] == {'🍍': {'count': 1, 'index': 0}}
174181

175-
assert not sogs_delete(client, "/room/test-room/reaction/10/🍍", global_mod).json['removed']
176-
assert sogs_delete(client, "/room/test-room/reaction/10/🍍", user).json['removed']
182+
assert sogs_delete(client, "/room/test-room/reaction/10/🍍", global_mod).json == {
183+
'removed': False,
184+
'seqno': seqno_10,
185+
}
186+
assert sogs_delete(client, "/room/test-room/reaction/10/🍍", user).json == {
187+
'removed': True,
188+
'seqno': seqno + 1,
189+
}
177190

178-
assert sogs_put(client, "/room/test-room/reaction/9/🍍", {}, user).json['added']
179-
assert sogs_put(client, "/room/test-room/reaction/9/🍍", {}, user2).json['added']
191+
assert sogs_put(client, "/room/test-room/reaction/9/🍍", {}, user).json == {
192+
'added': True,
193+
"seqno": seqno + 2,
194+
}
195+
assert sogs_put(client, "/room/test-room/reaction/9/🍍", {}, user2).json == {
196+
'added': True,
197+
"seqno": seqno + 3,
198+
}
180199
r = sogs_get(client, "/room/test-room/message/9", mod).json
181200
assert 'reactions' in r
182201
assert r.get('reactions') == {
@@ -263,27 +282,27 @@ def test_reaction_ordering(client, room, user, user2):
263282
for x in ("🖕", "f", "🍆", "y/n", "abcdefghijkl", "🍍"):
264283
r = sogs_put(client, f"/room/test-room/reaction/1/{x}", {}, user)
265284
assert r.status_code == 200
266-
assert r.json["added"]
267285
seqno += 1
286+
assert r.json == {"added": True, "seqno": seqno}
268287

269288
for x in ("‽", "abcdefghijkl", "f", "🍍", "🖕"):
270289
r = sogs_put(client, f"/room/test-room/reaction/2/{x}", {}, user2)
271290
assert r.status_code == 200
272-
assert r.json["added"]
273291
seqno += 1
292+
assert r.json == {"added": True, "seqno": seqno}
274293

275294
for x in ("🖕", "f", "🍆", "y/n", "abcdefghijkl", "🍍", "🫑"):
276295
r = sogs_put(client, f"/room/test-room/reaction/2/{x}", {}, user)
277296
assert r.status_code == 200
278-
assert r.json["added"]
279297
seqno += 1
298+
assert r.json == {"added": True, "seqno": seqno}
280299
seqno_2 = seqno
281300

282301
for x in ("abcdefghijkl", "f", "🍍", "🖕", "🎂"):
283302
r = sogs_put(client, f"/room/test-room/reaction/1/{x}", {}, user2)
284303
assert r.status_code == 200
285-
assert r.json["added"]
286304
seqno += 1
305+
assert r.json == {"added": True, "seqno": seqno}
287306

288307
u1 = [user.session_id]
289308
u2 = [user2.session_id]
@@ -316,7 +335,10 @@ def test_reaction_ordering(client, room, user, user2):
316335
]
317336

318337
# Deleting a user reaction while the post has other user reactions should not affect the order:
319-
assert sogs_delete(client, "/room/test-room/reaction/1/f", user).json['removed']
338+
assert sogs_delete(client, "/room/test-room/reaction/1/f", user).json == {
339+
'removed': True,
340+
'seqno': seqno + 1,
341+
}
320342
seqno += 1
321343
exp_reacts_1["f"]["count"] -= 1
322344
exp_reacts_1["f"]["reactors"] = u2
@@ -329,8 +351,14 @@ def test_reaction_ordering(client, room, user, user2):
329351

330352
# Deleting the last reaction and then adding it again should put it back at the *end*, not in
331353
# its original position:
332-
assert sogs_delete(client, "/room/test-room/reaction/1/f", user2).json['removed']
333-
assert sogs_put(client, "/room/test-room/reaction/1/f", {}, user2).json['added']
354+
assert sogs_delete(client, "/room/test-room/reaction/1/f", user2).json == {
355+
'removed': True,
356+
'seqno': seqno + 1,
357+
}
358+
assert sogs_put(client, "/room/test-room/reaction/1/f", {}, user2).json == {
359+
'added': True,
360+
'seqno': seqno + 2,
361+
}
334362
seqno += 2
335363

336364
for v in exp_reacts_1.values():

0 commit comments

Comments
 (0)