Skip to content

Commit 4eae51f

Browse files
dahliaclaude
andcommitted
Fix race conditions in poll voting
- Fix race condition in vote counting by using repository.countVotes() instead of manual increment - Add CAS support in KvRepository for concurrent vote handling - Add comprehensive tests for vote(), countVoters(), and countVotes() methods - Add polls prefix to KvStoreRepositoryPrefixes - Upgrade Fedify to 1.8.0-dev.910 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d719ad7 commit 4eae51f

File tree

8 files changed

+321
-49
lines changed

8 files changed

+321
-49
lines changed

CHANGES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ To be released.
1818
- Added `SessionPublishOptionsWithQuestion` interface.
1919
- Added `Bot.onVote` event.
2020
- Added `VoteEventHandler` type.
21+
- Added `KvStoreRepositoryPrefixes.polls` option.
2122

2223
- Added `@fedify/botkit/repository` module that provides repository
2324
implementations for BotKit.
@@ -32,7 +33,7 @@ To be released.
3233
- Added `Create` class.
3334
- Added `MemoryCachedRepository` class.
3435

35-
- Upgraded Fedify to 1.7.1.
36+
- Upgraded Fedify to 1.8.0.
3637

3738
[#7]: https://github.com/fedify-dev/botkit/issues/7
3839
[#8]: https://github.com/fedify-dev/botkit/pull/8

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"./text": "./src/text.ts"
2121
},
2222
"imports": {
23-
"@fedify/fedify": "jsr:@fedify/fedify@^1.7.1",
23+
"@fedify/fedify": "jsr:@fedify/fedify@^1.8.0-dev.910+8a000b1c",
2424
"@fedify/markdown-it-hashtag": "jsr:@fedify/markdown-it-hashtag@^0.3.0",
2525
"@fedify/markdown-it-mention": "jsr:@fedify/markdown-it-mention@^0.3.0",
2626
"@logtape/logtape": "jsr:@logtape/logtape@^1.0.0",

deno.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-lock.yaml

Lines changed: 11 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ packages:
33
- docs
44

55
catalog:
6-
"@fedify/fedify": ^1.7.1
6+
"@fedify/fedify": ^1.8.0-dev.910

src/bot-impl.ts

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -673,12 +673,16 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
673673
messageClasses.includes(replyTarget.class) &&
674674
object.name != null
675675
) {
676-
if (create.actorId?.href === session.actorId.href) return;
676+
if (
677+
create.actorId == null || create.actorId.href === session.actorId.href
678+
) {
679+
return;
680+
}
681+
const actorId = create.actorId;
677682
const actor = await create.getActor(ctx);
678683
if (actor == null) return;
679-
const pollMessage = await this.repository.getMessage(
680-
replyTarget.values.id as Uuid,
681-
);
684+
const messageId = replyTarget.values.id as Uuid;
685+
const pollMessage = await this.repository.getMessage(messageId);
682686
if (!(pollMessage instanceof Create)) return;
683687
const question = await pollMessage.getObject(ctx);
684688
if (
@@ -702,34 +706,37 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
702706
}
703707
const option = object.name.toString();
704708
if (!options.includes(option)) return;
705-
const updatedOptionNotes: Note[] = [...optionNotes];
706-
let i = 0;
707-
for (const note of updatedOptionNotes) {
708-
if (note.name === option) {
709-
const replies = await note.getReplies(ctx);
710-
if (replies != null && replies.totalItems != null) {
711-
updatedOptionNotes[i] = note.clone({
712-
replies: replies.clone({
713-
// FIXME: This way of updating vote count is not only inefficient,
714-
// but also can lead to incorrect counts if multiple votes are
715-
// cast at the same time.
716-
totalItems: replies.totalItems + 1,
717-
}),
718-
});
719-
}
720-
}
721-
i++;
722-
}
723-
const updatedQuestion = question.clone({
724-
inclusiveOptions: multiple ? updatedOptionNotes : [],
725-
exclusiveOptions: !multiple ? updatedOptionNotes : [],
726-
});
727-
const updatedPollMessage = pollMessage.clone({
728-
object: updatedQuestion,
729-
});
709+
let updatedQuestion: Question = question;
710+
let updatedPollMessage = pollMessage;
711+
await this.repository.vote(messageId, actorId, option);
730712
await this.repository.updateMessage(
731713
replyTarget.values.id as Uuid,
732-
() => updatedPollMessage,
714+
async () => {
715+
const votes = await this.repository.countVotes(messageId);
716+
const updatedOptionNotes: Note[] = [...optionNotes];
717+
let i = 0;
718+
for (const note of updatedOptionNotes) {
719+
if (note.name != null) {
720+
const replies = await note.getReplies(ctx);
721+
if (replies != null && replies.totalItems != null) {
722+
updatedOptionNotes[i] = note.clone({
723+
replies: replies.clone({
724+
totalItems: votes[note.name.toString()],
725+
}),
726+
});
727+
}
728+
}
729+
i++;
730+
}
731+
updatedQuestion = question.clone({
732+
inclusiveOptions: multiple ? updatedOptionNotes : [],
733+
exclusiveOptions: !multiple ? updatedOptionNotes : [],
734+
voters: await this.repository.countVoters(messageId),
735+
});
736+
return updatedPollMessage = pollMessage.clone({
737+
object: updatedQuestion,
738+
});
739+
},
733740
);
734741
const message = await createMessage(updatedQuestion, session, {});
735742
const vote: Vote<TContextData> = {

src/repository.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,5 +689,85 @@ for (const name in factories) {
689689
await repo.removeFollowee(followeeId);
690690
assert.deepStrictEqual(await repo.getFollowee(followeeId), undefined);
691691
});
692+
693+
test("poll voting", async () => {
694+
const messageId1 = "01945678-1234-7890-abcd-ef0123456789";
695+
const messageId2 = "01945678-5678-7890-abcd-ef0123456789";
696+
const voter1 = new URL("https://example.com/ap/actor/alice");
697+
const voter2 = new URL("https://example.com/ap/actor/bob");
698+
const voter3 = new URL("https://example.com/ap/actor/charlie");
699+
700+
// Initially, no votes exist
701+
assert.deepStrictEqual(await repo.countVoters(messageId1), 0);
702+
assert.deepStrictEqual(await repo.countVotes(messageId1), {});
703+
assert.deepStrictEqual(await repo.countVoters(messageId2), 0);
704+
assert.deepStrictEqual(await repo.countVotes(messageId2), {});
705+
706+
// Single voter, single option
707+
await repo.vote(messageId1, voter1, "option1");
708+
assert.deepStrictEqual(await repo.countVoters(messageId1), 1);
709+
assert.deepStrictEqual(await repo.countVotes(messageId1), {
710+
"option1": 1,
711+
});
712+
713+
// Same voter votes for same option again (should be ignored)
714+
await repo.vote(messageId1, voter1, "option1");
715+
assert.deepStrictEqual(await repo.countVoters(messageId1), 1);
716+
assert.deepStrictEqual(await repo.countVotes(messageId1), {
717+
"option1": 1,
718+
});
719+
720+
// Same voter votes for different option (multiple choice)
721+
await repo.vote(messageId1, voter1, "option2");
722+
assert.deepStrictEqual(await repo.countVoters(messageId1), 1);
723+
assert.deepStrictEqual(await repo.countVotes(messageId1), {
724+
"option1": 1,
725+
"option2": 1,
726+
});
727+
728+
// Different voter votes for same option
729+
await repo.vote(messageId1, voter2, "option1");
730+
assert.deepStrictEqual(await repo.countVoters(messageId1), 2);
731+
assert.deepStrictEqual(await repo.countVotes(messageId1), {
732+
"option1": 2,
733+
"option2": 1,
734+
});
735+
736+
// Third voter votes for new option
737+
await repo.vote(messageId1, voter3, "option3");
738+
assert.deepStrictEqual(await repo.countVoters(messageId1), 3);
739+
assert.deepStrictEqual(await repo.countVotes(messageId1), {
740+
"option1": 2,
741+
"option2": 1,
742+
"option3": 1,
743+
});
744+
745+
// Votes for different message should be separate
746+
await repo.vote(messageId2, voter1, "optionA");
747+
await repo.vote(messageId2, voter2, "optionB");
748+
assert.deepStrictEqual(await repo.countVoters(messageId2), 2);
749+
assert.deepStrictEqual(await repo.countVotes(messageId2), {
750+
"optionA": 1,
751+
"optionB": 1,
752+
});
753+
754+
// Original message votes should remain unchanged
755+
assert.deepStrictEqual(await repo.countVoters(messageId1), 3);
756+
assert.deepStrictEqual(await repo.countVotes(messageId1), {
757+
"option1": 2,
758+
"option2": 1,
759+
"option3": 1,
760+
});
761+
762+
// Test with empty options (edge case)
763+
await repo.vote(messageId1, voter1, "");
764+
assert.deepStrictEqual(await repo.countVoters(messageId1), 3);
765+
assert.deepStrictEqual(await repo.countVotes(messageId1), {
766+
"option1": 2,
767+
"option2": 1,
768+
"option3": 1,
769+
"": 1,
770+
});
771+
});
692772
});
693773
}

0 commit comments

Comments
 (0)