From 5428270e8e9b1d886d0a5059c2171addadbf3e66 Mon Sep 17 00:00:00 2001 From: Roman Emreis Date: Sun, 14 Jul 2024 15:06:03 +0400 Subject: [PATCH 1/2] * Added Distributed Counter to guarantee uniqueness of the generated short url * Updated and improved token generation logic, made if less predictable --- .../Features/Counter/CounterService.cs | 23 ++++++++ .../Shorty.API/Features/Urls/UrlRepository.cs | 55 +++++++++---------- backend/src/Shorty.API/Program.cs | 2 + backend/src/Shorty.API/ShortUrlToken.cs | 41 +++++++++++--- backend/src/Shorty.AppHost/Program.cs | 3 +- .../Shorty.API.Tests/ShortUrlTokenTests.cs | 43 ++++++++++++++- 6 files changed, 126 insertions(+), 41 deletions(-) create mode 100644 backend/src/Shorty.API/Features/Counter/CounterService.cs diff --git a/backend/src/Shorty.API/Features/Counter/CounterService.cs b/backend/src/Shorty.API/Features/Counter/CounterService.cs new file mode 100644 index 0000000..25fb5ee --- /dev/null +++ b/backend/src/Shorty.API/Features/Counter/CounterService.cs @@ -0,0 +1,23 @@ +using StackExchange.Redis; + +namespace Shorty.API.Features.Counter; + +public interface ICounterService +{ + Task IncrementAsync(); +} + +internal sealed class CounterService(IConnectionMultiplexer connectionMultiplexer) : ICounterService +{ + private const string CounterKey = "shorty_counter"; + + private readonly IDatabase _redis = connectionMultiplexer.GetDatabase(); + + public async Task IncrementAsync() + { + const long supplement = ShortUrlToken.MinValue; + + var value = await _redis.StringIncrementAsync(CounterKey); + return value + supplement; + } +} \ No newline at end of file diff --git a/backend/src/Shorty.API/Features/Urls/UrlRepository.cs b/backend/src/Shorty.API/Features/Urls/UrlRepository.cs index de2532c..686cf52 100644 --- a/backend/src/Shorty.API/Features/Urls/UrlRepository.cs +++ b/backend/src/Shorty.API/Features/Urls/UrlRepository.cs @@ -1,6 +1,7 @@ using Dapper; using Microsoft.Extensions.Caching.Distributed; using Npgsql; +using Shorty.API.Features.Counter; namespace Shorty.API.Features.Urls; @@ -10,53 +11,47 @@ public interface IUrlRepository Task GetAsync(string token, CancellationToken cancellationToken = default); } -internal sealed class UrlRepository(IDistributedCache cache, NpgsqlConnection db) : IUrlRepository +internal sealed class UrlRepository(IDistributedCache cache, ICounterService counter, NpgsqlConnection db) : IUrlRepository { public async Task SaveAsync(string url, CancellationToken cancellationToken = default) { const string sql = """ INSERT INTO shorty_urls (token, url, created_at) - VALUES (@value, @url, @createdAt) - ON CONFLICT (token) DO NOTHING; + VALUES (@value, @url, @createdAt); """; - var count = 0; - var value = string.Empty; - var createdAt = DateTime.UtcNow; - - while (count == 0) - { - cancellationToken.ThrowIfCancellationRequested(); - - value = ShortUrlToken.NewToken(); - count = await db.ExecuteAsync(sql, new { value, url, createdAt }); - } + var createdAt = DateTime.UtcNow; + var count = await counter.IncrementAsync(); + string value = ShortUrlToken.NewToken(count); + await db.ExecuteAsync(sql, new { value, url, createdAt }); await SaveToCacheAsync(value, url, cancellationToken); - + return value; } public async Task GetAsync(string token, CancellationToken cancellationToken = default) { - var url = await cache.GetStringAsync(token, cancellationToken); - if (string.IsNullOrEmpty(url)) + var url = await cache.GetStringAsync(token, cancellationToken); + if (!string.IsNullOrEmpty(url)) { - const string sql = - """ - SELECT url - FROM shorty_urls - WHERE token = @token - LIMIT 1 - """; + return url; + } - url = await db.QueryFirstOrDefaultAsync(sql, new { token }); + const string sql = + """ + SELECT url + FROM shorty_urls + WHERE token = @token + LIMIT 1 + """; - if (!string.IsNullOrEmpty(url)) - { - await SaveToCacheAsync(token, url, cancellationToken); - } + url = await db.QueryFirstOrDefaultAsync(sql, new { token }); + + if (!string.IsNullOrEmpty(url)) + { + await SaveToCacheAsync(token, url, cancellationToken); } return url; @@ -72,7 +67,7 @@ private async Task SaveToCacheAsync(string token, string url, CancellationToken private static DistributedCacheEntryOptions CreateDefaultOptions() => new DistributedCacheEntryOptions { - SlidingExpiration = TimeSpan.FromHours(5), + SlidingExpiration = TimeSpan.FromHours(5), AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(30) }; } diff --git a/backend/src/Shorty.API/Program.cs b/backend/src/Shorty.API/Program.cs index 2ea729e..79c08c7 100644 --- a/backend/src/Shorty.API/Program.cs +++ b/backend/src/Shorty.API/Program.cs @@ -1,5 +1,6 @@ using Shorty.API.Features.Urls; using System.Text.Json.Serialization; +using Shorty.API.Features.Counter; var builder = WebApplication.CreateSlimBuilder(args); @@ -19,6 +20,7 @@ builder.Services.AddProblemDetails(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/backend/src/Shorty.API/ShortUrlToken.cs b/backend/src/Shorty.API/ShortUrlToken.cs index c79d108..e3b1979 100644 --- a/backend/src/Shorty.API/ShortUrlToken.cs +++ b/backend/src/Shorty.API/ShortUrlToken.cs @@ -2,24 +2,51 @@ public readonly struct ShortUrlToken { - private const int DefaultLength = 7; - private const string Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - + internal const long + MinValue = 56_800_235_584L, + MaxValue = 3_521_614_606_207L; + + private const int + DefaultLength = 7, + Base = 62; + + private const string Chars = "QoNPMlEDkABC06789zyxwvutsrq12435pOnmLKjZYXWVUTSRihgfedcbJIHGFa"; + private readonly string _value; - public ShortUrlToken() + private ShortUrlToken(long count) { Span token = stackalloc char[DefaultLength]; - for (int i = 0; i < token.Length; ++i) + var j = DefaultLength; + while (count != 0) { - token[i] = Chars[Random.Shared.Next(Chars.Length)]; + var i = (byte) (count % Base); + token[--j] = Chars[i]; + count /= Base; } _value = new string(token); } - internal static ShortUrlToken NewToken() => new(); + /// + /// Idempotent creates a unique 7-character token based on the provided count value. + /// Passing the same count value leads to the same result. + ///
+ /// The overall number of available unique tokens is 3 464 814 370 623, + /// for the greater number of tokens the length should be increased to 8 e.t.c + ///
+ /// A value that a token will be based on + /// + /// A 7-character token based on the provided count value. + /// + internal static ShortUrlToken NewToken(long count) + { + ArgumentOutOfRangeException.ThrowIfLessThan(count, MinValue); + ArgumentOutOfRangeException.ThrowIfGreaterThan(count, MaxValue); + + return new ShortUrlToken(count); + } public static implicit operator string(ShortUrlToken token) => token._value; } diff --git a/backend/src/Shorty.AppHost/Program.cs b/backend/src/Shorty.AppHost/Program.cs index bc149eb..9a79a46 100644 --- a/backend/src/Shorty.AppHost/Program.cs +++ b/backend/src/Shorty.AppHost/Program.cs @@ -21,7 +21,8 @@ .WithReference(postgres) .WithReference(redis) .WaitFor(postgres) - .WaitFor(redis); + .WaitFor(redis) + .WithReplicas(3); // reverse proxy var proxy = builder.AddYarp("ingress") diff --git a/backend/tests/Shorty.API.Tests/ShortUrlTokenTests.cs b/backend/tests/Shorty.API.Tests/ShortUrlTokenTests.cs index 9c2a40d..7ae03f2 100644 --- a/backend/tests/Shorty.API.Tests/ShortUrlTokenTests.cs +++ b/backend/tests/Shorty.API.Tests/ShortUrlTokenTests.cs @@ -1,12 +1,49 @@ +using System.Diagnostics; + namespace Shorty.API.Tests; public class ShortUrlTokenTests { [Fact] - public void GetValue_CreatesA7CharString() + public void NewToken_CreatesA7CharString() { - string token = new ShortUrlToken(); - + string token = ShortUrlToken.NewToken(56_800_235_584); + token.Should().HaveLength(7); } + + [Fact] + public void NewToken_CreatesAnIdempotentToken() + { + string token1 = ShortUrlToken.NewToken(56_800_235_586); + string token2 = ShortUrlToken.NewToken(56_800_235_586); + + token1.Should().Be(token2); + } + + [Theory] + [InlineData(56_800_235_583L)] + [InlineData(3_521_614_606_208L)] + public void NewToken_Count_OutsideOfMaxOrMinValues_ThrowsArgumentOutOfRangeException(long count) + { + var act = () => ShortUrlToken.NewToken(count); + + act.Should().ThrowExactly(); + } + + [Fact] + public void NewToken_GeneratesMoreThan5MPerSecond() + { + var ct = new CancellationTokenSource(); + ct.CancelAfter(TimeSpan.FromSeconds(1)); + + int ops = 0; + for (;ops < int.MaxValue; ++ops) + { + if (ct.IsCancellationRequested) break; + _ = ShortUrlToken.NewToken(ops + ShortUrlToken.MinValue); + } + + ops.Should().BeGreaterThan(5_000_000); + } } \ No newline at end of file From a7f3bbb6f8ef53941a251cc882e2d259de319046 Mon Sep 17 00:00:00 2001 From: Roman Emreis Date: Wed, 11 Sep 2024 16:13:40 +0400 Subject: [PATCH 2/2] Updated .NET Aspire to 8.2.0 --- .../Shorty.API/Features/Urls/UrlRepository.cs | 22 ++++++------------- backend/src/Shorty.API/Program.cs | 13 ++++++----- backend/src/Shorty.API/ShortUrlToken.cs | 6 +++-- backend/src/Shorty.API/Shorty.API.csproj | 4 ++-- backend/src/Shorty.API/Shorty.API.http | 9 ++++---- .../src/Shorty.AppHost/Shorty.AppHost.csproj | 8 +++---- .../appsettings.Development.json | 1 + backend/src/Shorty.AppHost/appsettings.json | 1 + .../Shorty.ServiceDefaults.csproj | 4 ++-- .../Shorty.API.Tests/Shorty.API.Tests.csproj | 4 ++-- .../Shorty.AppHost.Tests.csproj | 8 +++---- 11 files changed, 39 insertions(+), 41 deletions(-) diff --git a/backend/src/Shorty.API/Features/Urls/UrlRepository.cs b/backend/src/Shorty.API/Features/Urls/UrlRepository.cs index 686cf52..061a285 100644 --- a/backend/src/Shorty.API/Features/Urls/UrlRepository.cs +++ b/backend/src/Shorty.API/Features/Urls/UrlRepository.cs @@ -24,7 +24,7 @@ INSERT INTO shorty_urls (token, url, created_at) var createdAt = DateTime.UtcNow; var count = await counter.IncrementAsync(); string value = ShortUrlToken.NewToken(count); - + await db.ExecuteAsync(sql, new { value, url, createdAt }); await SaveToCacheAsync(value, url, cancellationToken); @@ -34,30 +34,22 @@ INSERT INTO shorty_urls (token, url, created_at) public async Task GetAsync(string token, CancellationToken cancellationToken = default) { var url = await cache.GetStringAsync(token, cancellationToken); - if (!string.IsNullOrEmpty(url)) - { - return url; - } + if (!string.IsNullOrEmpty(url)) return url; const string sql = """ - SELECT url - FROM shorty_urls + SELECT url FROM shorty_urls WHERE token = @token LIMIT 1 """; url = await db.QueryFirstOrDefaultAsync(sql, new { token }); - - if (!string.IsNullOrEmpty(url)) - { - await SaveToCacheAsync(token, url, cancellationToken); - } + await SaveToCacheAsync(token, url, cancellationToken); return url; } - private async Task SaveToCacheAsync(string token, string url, CancellationToken cancellationToken) + private async Task SaveToCacheAsync(string token, string? url, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(url)) return; @@ -67,7 +59,7 @@ private async Task SaveToCacheAsync(string token, string url, CancellationToken private static DistributedCacheEntryOptions CreateDefaultOptions() => new DistributedCacheEntryOptions { - SlidingExpiration = TimeSpan.FromHours(5), - AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(30) + SlidingExpiration = TimeSpan.FromHours(1), + AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1) }; } diff --git a/backend/src/Shorty.API/Program.cs b/backend/src/Shorty.API/Program.cs index 79c08c7..d567d35 100644 --- a/backend/src/Shorty.API/Program.cs +++ b/backend/src/Shorty.API/Program.cs @@ -1,5 +1,6 @@ using Shorty.API.Features.Urls; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Server.Kestrel.Core; using Shorty.API.Features.Counter; var builder = WebApplication.CreateSlimBuilder(args); @@ -19,8 +20,8 @@ builder.Services.AddProblemDetails(); -builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -31,18 +32,18 @@ app.MapDefaultEndpoints(); -app.MapGet("/health", () => Results.Ok("healthy")); -app.MapGet("/{token}", async (IUrlRepository urlService, string token, CancellationToken cancellationToken) => +app.MapGet("/health", static () => Results.Ok("healthy")); +app.MapGet("/{token}", async (IUrlRepository repository, string token, CancellationToken cancellationToken) => { - var url = await urlService.GetAsync(token, cancellationToken); + var url = await repository.GetAsync(token, cancellationToken); return string.IsNullOrEmpty(url) ? Results.NotFound() : Results.Redirect(url); }); -app.MapPost("/create", async (IUrlRepository urlService, CreateShortUrl command, CancellationToken cancellationToken) => +app.MapPost("/create", async (IUrlRepository repository, CreateShortUrl command, CancellationToken cancellationToken) => { - var token = await urlService.SaveAsync(command.Url, cancellationToken); + var token = await repository.SaveAsync(command.Url, cancellationToken); return Results.Ok(token); }); diff --git a/backend/src/Shorty.API/ShortUrlToken.cs b/backend/src/Shorty.API/ShortUrlToken.cs index e3b1979..087d13c 100644 --- a/backend/src/Shorty.API/ShortUrlToken.cs +++ b/backend/src/Shorty.API/ShortUrlToken.cs @@ -10,7 +10,7 @@ private const int DefaultLength = 7, Base = 62; - private const string Chars = "QoNPMlEDkABC06789zyxwvutsrq12435pOnmLKjZYXWVUTSRihgfedcbJIHGFa"; + private const string Chars = "QoNPMlEDkABC06789zxyvwustrq21453pOnmLKjZYXWVUTSRihgfedcbJIHGFa"; private readonly string _value; @@ -21,7 +21,7 @@ private ShortUrlToken(long count) var j = DefaultLength; while (count != 0) { - var i = (byte) (count % Base); + int i = (int) (count % Base); token[--j] = Chars[i]; count /= Base; } @@ -48,5 +48,7 @@ internal static ShortUrlToken NewToken(long count) return new ShortUrlToken(count); } + public override string ToString() => _value; + public static implicit operator string(ShortUrlToken token) => token._value; } diff --git a/backend/src/Shorty.API/Shorty.API.csproj b/backend/src/Shorty.API/Shorty.API.csproj index cf4aae5..ec88cb2 100644 --- a/backend/src/Shorty.API/Shorty.API.csproj +++ b/backend/src/Shorty.API/Shorty.API.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/backend/src/Shorty.API/Shorty.API.http b/backend/src/Shorty.API/Shorty.API.http index f111443..7bd384a 100644 --- a/backend/src/Shorty.API/Shorty.API.http +++ b/backend/src/Shorty.API/Shorty.API.http @@ -1,20 +1,21 @@ -@Shorty.API_HostAddress = http://localhost:5254 +@host = http://localhost:5254 ### -POST {{Shorty.API_HostAddress}}/create +POST {{host}}/create Accept: application/json Content-Type: application/json + { "url": "https://github.com/RomanEmreis/shorty" } ### -GET {{Shorty.API_HostAddress}}/CYWQ6 +GET {{host}}/oQQQQQo Accept: application/json ### -GET {{Shorty.API_HostAddress}}/health +GET {{host}}/health Accept: application/json ### diff --git a/backend/src/Shorty.AppHost/Shorty.AppHost.csproj b/backend/src/Shorty.AppHost/Shorty.AppHost.csproj index 4cd9f90..d9e99df 100644 --- a/backend/src/Shorty.AppHost/Shorty.AppHost.csproj +++ b/backend/src/Shorty.AppHost/Shorty.AppHost.csproj @@ -11,10 +11,10 @@ - - - - + + + + diff --git a/backend/src/Shorty.AppHost/appsettings.Development.json b/backend/src/Shorty.AppHost/appsettings.Development.json index f6fc262..f6fb8f2 100644 --- a/backend/src/Shorty.AppHost/appsettings.Development.json +++ b/backend/src/Shorty.AppHost/appsettings.Development.json @@ -36,6 +36,7 @@ "Metadata": { "ConsecutiveFailuresHealthPolicy.Threshold": "3" }, + "LoadBalancingPolicy": "RoundRobin", "Destinations": { "api": { "Address": "http://shorty-api" diff --git a/backend/src/Shorty.AppHost/appsettings.json b/backend/src/Shorty.AppHost/appsettings.json index 13e8288..1c233f6 100644 --- a/backend/src/Shorty.AppHost/appsettings.json +++ b/backend/src/Shorty.AppHost/appsettings.json @@ -37,6 +37,7 @@ "Metadata": { "ConsecutiveFailuresHealthPolicy.Threshold": "3" }, + "LoadBalancingPolicy": "RoundRobin", "Destinations": { "api": { "Address": "http://shorty-api" diff --git a/backend/src/Shorty.ServiceDefaults/Shorty.ServiceDefaults.csproj b/backend/src/Shorty.ServiceDefaults/Shorty.ServiceDefaults.csproj index 4fd3ef6..2609cf5 100644 --- a/backend/src/Shorty.ServiceDefaults/Shorty.ServiceDefaults.csproj +++ b/backend/src/Shorty.ServiceDefaults/Shorty.ServiceDefaults.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/backend/tests/Shorty.API.Tests/Shorty.API.Tests.csproj b/backend/tests/Shorty.API.Tests/Shorty.API.Tests.csproj index b010f61..9110c80 100644 --- a/backend/tests/Shorty.API.Tests/Shorty.API.Tests.csproj +++ b/backend/tests/Shorty.API.Tests/Shorty.API.Tests.csproj @@ -11,9 +11,9 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/backend/tests/Shorty.AppHost.Tests/Shorty.AppHost.Tests.csproj b/backend/tests/Shorty.AppHost.Tests/Shorty.AppHost.Tests.csproj index c1bd8e8..d394ff7 100644 --- a/backend/tests/Shorty.AppHost.Tests/Shorty.AppHost.Tests.csproj +++ b/backend/tests/Shorty.AppHost.Tests/Shorty.AppHost.Tests.csproj @@ -9,15 +9,15 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive