Skip to content

Commit 11c1871

Browse files
committed
fix: handle race condition when posting link
Fix #30
1 parent fb88c9c commit 11c1871

4 files changed

Lines changed: 83 additions & 29 deletions

File tree

ApplicationData/ApplicationData.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<PackageReference Include="FruityFoundation.Base" Version="1.1.2" />
1717
<PackageReference Include="FruityFoundation.FsBase" Version="1.1.2" />
1818
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
19+
<PackageReference Include="ResultMonad" Version="1.0.1" />
1920
<PackageReference Include="SnooBrowser" Version="3.1.0" />
2021
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
2122
</ItemGroup>

ApplicationData/Services/LinkProvider.cs

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using FruityFoundation.Base.Extensions;
55
using FruityFoundation.Base.Structures;
66
using Npgsql;
7+
using ResultMonad;
78

89
namespace ApplicationData.Services;
910

@@ -92,29 +93,48 @@ public async Task DeleteLinkById(int linkId)
9293
await tx.CommitAsync();
9394
}
9495

95-
public async Task<int> CreateLink(NewLink link)
96+
public abstract record CreateLinkError
9697
{
97-
await using var tx = await _db.CreateTransactionAsync();
98-
var linkIdResult = (int?)await tx.ExecuteSqlScalarAsync(
99-
@"INSERT INTO app.links (reddit_post_id, link_url, link_type, created_at, is_deleted, owner)
100-
VALUES (@redditPostId, @linkUrl, @linkType, NOW(), false, @userId)
101-
RETURNING link_id",
102-
new NpgsqlParameter[]
103-
{
104-
new("@redditPostId", link.RedditPostId),
105-
new("@linkUrl", link.LinkUrl),
106-
new("@linkType", link.LinkType.RawValue),
107-
new("@userId", link.OwnerUserId)
108-
});
109-
110-
if (!linkIdResult.ToMaybe().Try(out var linkId))
111-
throw new ApplicationException("Failed to create link");
112-
113-
await QueueRedditPostIdForUpdate(tx, link.RedditPostId);
98+
public T Switch<T>(Func<T> linkAlreadyExists) => this switch
99+
{
100+
LinkAlreadyExists => linkAlreadyExists(),
101+
_ => throw new NotImplementedException($"Type not implemented: {GetType().FullName}")
102+
};
103+
}
114104

115-
await tx.CommitAsync();
105+
public record LinkAlreadyExists : CreateLinkError;
116106

117-
return linkId;
107+
public async Task<Result<int, CreateLinkError>> CreateLink(NewLink link)
108+
{
109+
try
110+
{
111+
await using var tx = await _db.CreateTransactionAsync();
112+
var linkIdResult = (int?)await tx.ExecuteSqlScalarAsync("""
113+
INSERT INTO app.links (reddit_post_id, link_url, link_type, created_at, is_deleted, owner)
114+
VALUES (@redditPostId, @linkUrl, @linkType, NOW(), false, @userId)
115+
RETURNING link_id
116+
""",
117+
new NpgsqlParameter[]
118+
{
119+
new("@redditPostId", link.RedditPostId),
120+
new("@linkUrl", link.LinkUrl),
121+
new("@linkType", link.LinkType.RawValue),
122+
new("@userId", link.OwnerUserId)
123+
});
124+
125+
if (!linkIdResult.ToMaybe().Try(out var linkId))
126+
throw new ApplicationException("Failed to create link");
127+
128+
await QueueRedditPostIdForUpdate(tx, link.RedditPostId);
129+
130+
await tx.CommitAsync();
131+
132+
return Result.Ok<int, CreateLinkError>(linkId);
133+
}
134+
catch (PostgresException ex) when (ex is { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "UQ_reddit_post_id_link_url_link_type_is_deleted_owner" })
135+
{
136+
return Result.Fail<int, CreateLinkError>(new LinkAlreadyExists());
137+
}
118138
}
119139

120140
public async Task<IReadOnlyCollection<Link>> GetAllLinksByUserId(int userId)

WebApi/Controllers/LinkController.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,6 @@ public async Task<IActionResult> SubmitLink([FromBody] SubmitLinkRequest linkReq
5353
var validUrl = GetValidUriOrFail(linkRequest.LinkUrl);
5454
var linkKind = GetLinkKindOrFail(linkRequest.LinkType!.Value);
5555

56-
if ((await _linkProvider.FindLink(UserId, linkRequest.RedditPostId, validUrl.OriginalString, linkKind)).HasValue)
57-
return new ConflictObjectResult(new LinkAlreadyExistsError(
58-
Message: TranslatedStrings.LinkController.LinkAlreadyExists,
59-
RedditPostId: linkRequest.RedditPostId,
60-
Url: validUrl.OriginalString,
61-
LinkType: linkRequest.LinkType
62-
));
63-
6456
var maybeSubmission = await _submissionBrowser.GetSubmission(LinkThing.CreateFromShortId(linkRequest.RedditPostId));
6557

6658
if (!maybeSubmission.Try(out var submission) || submission.IsArchived || submission.IsLocked)
@@ -69,13 +61,24 @@ public async Task<IActionResult> SubmitLink([FromBody] SubmitLinkRequest linkReq
6961
RedditPostId: linkRequest.RedditPostId
7062
));
7163

72-
await _linkProvider.CreateLink(new NewLink(
64+
var createResult = await _linkProvider.CreateLink(new NewLink(
7365
redditPostId: linkRequest.RedditPostId,
7466
linkUrl: validUrl.OriginalString,
7567
linkKind,
7668
ownerUserId: UserId
7769
));
7870

71+
if (!createResult.TrySuccess(out _, out var error))
72+
{
73+
return error!.Switch(
74+
linkAlreadyExists: () => new ConflictObjectResult(new LinkAlreadyExistsError(
75+
Message: TranslatedStrings.LinkController.LinkAlreadyExists,
76+
RedditPostId: linkRequest.RedditPostId,
77+
Url: validUrl.OriginalString,
78+
LinkType: linkRequest.LinkType
79+
)));
80+
}
81+
7982
// We are technically violating the HTTP spec here but not sending back the location of the resource we just created, but oh well.
8083
return new StatusCodeResult((int)HttpStatusCode.Created);
8184
}

WebApi/Util/ResultExtensions.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using ResultMonad;
2+
3+
namespace WebApi.Util;
4+
5+
/// <summary>
6+
/// ResultMonad extensions
7+
/// </summary>
8+
public static class ResultExtensions
9+
{
10+
/// <summary>
11+
/// Return true if the Result is a successful one.
12+
/// Assign success and error out parameters.
13+
/// </summary>
14+
public static bool TrySuccess<TSuccess, TError>(this Result<TSuccess, TError> item, out TSuccess? success,
15+
out TError? error)
16+
{
17+
if (item.IsSuccess)
18+
{
19+
success = item.Value;
20+
error = default;
21+
}
22+
else
23+
{
24+
success = default;
25+
error = item.Error;
26+
}
27+
28+
return item.IsSuccess;
29+
}
30+
}

0 commit comments

Comments
 (0)