Skip to content

Commit 4c9895f

Browse files
committed
(#102) ContentProxy: finish working FileCache
1 parent 37fed71 commit 4c9895f

7 files changed

Lines changed: 130 additions & 28 deletions

File tree

Emulsion.ContentProxy/Emulsion.ContentProxy.fsproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717

1818
<ItemGroup>
1919
<PackageReference Include="Hashids.net" Version="1.4.1" />
20+
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
2021
<PackageReference Include="Serilog" Version="2.10.0" />
22+
<PackageReference Include="SimpleBase" Version="3.1.0" />
2123
</ItemGroup>
2224

2325
</Project>

Emulsion.ContentProxy/FileCache.fs

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,53 +2,103 @@
22

33
open System
44
open System.IO
5+
open System.Net.Http
56
open System.Security.Cryptography
67
open System.Text
7-
88
open System.Threading
9-
open Emulsion.Settings
9+
1010
open Serilog
11+
open SimpleBase
12+
13+
open Emulsion.Settings
1114

1215
type DownloadRequest = {
1316
Uri: Uri
1417
CacheKey: string
1518
Size: uint64
1619
}
1720

21+
module Base58 =
22+
/// Suggested by @ttldtor.
23+
let M4N71KR = Base58(Base58Alphabet "123456789qwertyuiopasdfghjkzxcvbnmQWERTYUPASDFGHJKLZXCVBNM")
24+
1825
module FileCache =
19-
let FileName(sha256: SHA256, cacheKey: string): string =
26+
let EncodeFileName(sha256: SHA256, cacheKey: string): string =
2027
cacheKey
2128
|> Encoding.UTF8.GetBytes
2229
|> sha256.ComputeHash
23-
|> Convert.ToBase64String
30+
|> Base58.M4N71KR.Encode
31+
32+
let DecodeFileNameToSha256Hash(fileName: string): byte[] =
33+
(Base58.M4N71KR.Decode fileName).ToArray()
2434

25-
// TODO: Total cache limit
2635
type FileCache(logger: ILogger,
2736
settings: FileCacheSettings,
37+
httpClientFactory: IHttpClientFactory,
2838
sha256: SHA256) =
2939

3040
let getFilePath(cacheKey: string) =
31-
Path.Combine(settings.Directory, FileCache.FileName(sha256, cacheKey))
41+
Path.Combine(settings.Directory, FileCache.EncodeFileName(sha256, cacheKey))
3242

3343
let getFromCache(cacheKey: string) = async {
3444
let path = getFilePath cacheKey
3545
return
3646
if File.Exists path then
37-
Some(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Delete))
47+
Some(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read|||FileShare.Delete))
3848
else
3949
None
4050
}
4151

42-
// TODO: Check total item size, too
52+
let assertCacheValid() = async {
53+
Directory.EnumerateFileSystemEntries settings.Directory
54+
|> Seq.iter(fun entry ->
55+
let entryName = Path.GetFileName entry
56+
57+
if not <| File.Exists entry
58+
then failwith $"Cache directory invalid: contains a subdirectory: \"{entryName}\"."
59+
60+
let hash = FileCache.DecodeFileNameToSha256Hash entryName
61+
if hash.Length <> sha256.HashSize / 8
62+
then failwith (
63+
$"Cache directory invalid: contains entry \"{entryName}\" which doesn't correspond to a " +
64+
"base58-encoded SHA-256 hash."
65+
)
66+
)
67+
}
68+
4369
let ensureFreeCache size = async {
44-
if size > settings.FileSizeLimitBytes then
70+
if size > settings.FileSizeLimitBytes || size > settings.TotalCacheSizeLimitBytes then
4571
return false
4672
else
47-
return failwith "TODO: Sanity check that cache only has files"
73+
do! assertCacheValid()
74+
75+
let allEntries =
76+
Directory.EnumerateFileSystemEntries settings.Directory
77+
|> Seq.map FileInfo
78+
79+
// Now, sort the entries from newest to oldest, and start deleting if required at a point when we understand
80+
// that there are too much files:
81+
let entriesByPriority =
82+
allEntries
83+
|> Seq.sortByDescending(fun info -> info.LastWriteTimeUtc)
84+
|> Seq.toArray
85+
86+
let mutable currentSize = 0UL
87+
for info in entriesByPriority do
88+
currentSize <- currentSize + Checked.uint64 info.Length
89+
if currentSize + size > settings.TotalCacheSizeLimitBytes then
90+
logger.Information("Deleting a cache item \"{FileName}\" ({Size} bytes)", info.Name, info.Length)
91+
info.Delete()
92+
93+
return true
4894
}
4995

50-
let download uri: Async<Stream> = async {
51-
return failwithf "TODO: Download the URI and return a stream"
96+
let download(uri: Uri): Async<Stream> = async {
97+
let! ct = Async.CancellationToken
98+
99+
use client = httpClientFactory.CreateClient()
100+
let! response = Async.AwaitTask <| client.GetAsync(uri, ct)
101+
return! Async.AwaitTask <| response.EnsureSuccessStatusCode().Content.ReadAsStreamAsync()
52102
}
53103

54104
let downloadIntoCacheAndGet uri cacheKey: Async<Stream> = async {
@@ -57,41 +107,43 @@ type FileCache(logger: ILogger,
57107
let path = getFilePath cacheKey
58108
logger.Information("Saving {Uri} to path {Path}…", uri, path)
59109

60-
use cachedFile = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None)
61-
do! Async.AwaitTask(stream.CopyToAsync(cachedFile, ct))
62-
logger.Information("Download successful: \"{Uri}\" to \"{Path}\".")
110+
do! async { // to limit the cachedFile scope
111+
use cachedFile = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None)
112+
do! Async.AwaitTask(stream.CopyToAsync(cachedFile, ct))
113+
logger.Information("Download successful: \"{Uri}\" to \"{Path}\".")
114+
}
63115

64116
let! file = getFromCache cacheKey
65117
return upcast Option.get file
66118
}
67119

68120
let cancellation = new CancellationTokenSource()
69-
let processRequest request: Async<Stream option> = async {
121+
let processRequest request: Async<Stream> = async {
70122
logger.Information("Cache lookup for content {Uri} (cache key {CacheKey})", request.Uri, request.CacheKey)
71123
match! getFromCache request.CacheKey with
72124
| Some content ->
73125
logger.Information("Cache hit for content {Uri} (cache key {CacheKey})", request.Uri, request.CacheKey)
74-
return Some content
126+
return content
75127
| None ->
76128
logger.Information("No cache hit for content {Uri} (cache key {CacheKey}), will download", request.Uri, request.CacheKey)
77129
let! shouldCache = ensureFreeCache request.Size
78130
if shouldCache then
79131
logger.Information("Resource {Uri} (cache key {CacheKey}, {Size} bytes) will fit into cache, caching", request.Uri, request.CacheKey, request.Size)
80132
let! result = downloadIntoCacheAndGet request.Uri request.CacheKey
81133
logger.Information("Resource {Uri} (cache key {CacheKey}, {Size} bytes) downloaded", request.Uri, request.CacheKey, request.Size)
82-
return Some result
134+
return result
83135
else
84136
logger.Information("Resource {Uri} (cache key {CacheKey}) won't fit into cache, directly downloading", request.Uri, request.CacheKey)
85137
let! result = download request.Uri
86-
return Some result
138+
return result
87139
}
88140

89141
let rec processLoop(processor: MailboxProcessor<_ * AsyncReplyChannel<_>>) = async {
90142
while true do
91143
let! request, replyChannel = processor.Receive()
92144
try
93145
let! result = processRequest request
94-
replyChannel.Reply result
146+
replyChannel.Reply(Some result)
95147
with
96148
| ex ->
97149
logger.Error(ex, "Exception while processing the file download queue")

Emulsion.TestFramework/Emulsion.TestFramework.fsproj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
44
<TargetFramework>net6.0</TargetFramework>
55
<GenerateDocumentationFile>true</GenerateDocumentationFile>
6+
<OutputType>Library</OutputType>
67
</PropertyGroup>
78

89
<ItemGroup>
@@ -13,6 +14,7 @@
1314
<Compile Include="Exceptions.fs" />
1415
<Compile Include="TelegramClientMock.fs" />
1516
<Compile Include="WebFileStorage.fs" />
17+
<Compile Include="SimpleHttpClientFactory.fs" />
1618
</ItemGroup>
1719

1820
<ItemGroup>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Emulsion.TestFramework
2+
3+
open System.Net.Http
4+
5+
type SimpleHttpClientFactory() =
6+
interface IHttpClientFactory with
7+
member this.CreateClient _ = new HttpClient()
Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,38 @@
11
namespace Emulsion.TestFramework
22

33
open System
4+
open System.Net
5+
open System.Net.Sockets
6+
7+
open Microsoft.AspNetCore.Builder
8+
open Microsoft.AspNetCore.Http
9+
10+
module private NetUtil =
11+
let findFreePort() =
12+
use socket = new Socket(SocketType.Stream, ProtocolType.Tcp)
13+
socket.Bind(IPEndPoint(IPAddress.Loopback, 0))
14+
(socket.LocalEndPoint :?> IPEndPoint).Port
415

516
type WebFileStorage(data: Map<string, byte[]>) =
17+
let url = $"http://localhost:{NetUtil.findFreePort()}"
18+
19+
let startWebApplication() =
20+
let builder = WebApplication.CreateBuilder()
21+
let app = builder.Build()
22+
app.MapGet("/{entry}", Func<_, _>(fun (entry: string) -> task {
23+
return Results.Bytes(data[entry])
24+
})) |> ignore
25+
app, app.RunAsync url
26+
27+
let app, task = startWebApplication()
28+
629
member _.Link(entry: string): Uri =
7-
failwith "todo"
30+
Uri $"{url}/{entry}"
831

932
member _.Content(entry: string): byte[] =
10-
failwith "todo"
33+
data[entry]
1134

1235
interface IDisposable with
13-
member this.Dispose(): unit = failwith "todo"
14-
36+
member this.Dispose(): unit =
37+
app.StopAsync().Wait()
38+
task.Wait()

Emulsion.Tests/ContentProxy/FileCacheTests.fs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type FileCacheTests(outputHelper: ITestOutputHelper) =
3232
TotalCacheSizeLimitBytes = totalLimitBytes
3333
}
3434

35-
new FileCache(xunitLogger outputHelper, settings, sha256)
35+
new FileCache(xunitLogger outputHelper, settings, SimpleHttpClientFactory(), sha256)
3636

3737
let assertCacheState(entries: (string * byte[]) seq) =
3838
let files =
@@ -46,10 +46,20 @@ type FileCacheTests(outputHelper: ITestOutputHelper) =
4646

4747
let entries =
4848
entries
49-
|> Seq.map(fun (k, v) -> FileCache.FileName(sha256, k), v)
49+
|> Seq.map(fun (k, v) -> FileCache.EncodeFileName(sha256, k), v)
5050
|> Map.ofSeq
5151

52-
Assert.Equal<IEnumerable<_>>(entries, files)
52+
Assert.Equal<IEnumerable<_>>(entries.Keys, files.Keys)
53+
for key in entries.Keys do
54+
Assert.Equal<IEnumerable<_>>(entries[key], files[key])
55+
56+
[<Fact>]
57+
member _.``File cache should throw a validation exception if the cache directory contains directories``(): unit =
58+
Assert.False true
59+
60+
[<Fact>]
61+
member _.``File cache should throw a validation exception if the cache directory contains non-conventionally-named files``(): unit =
62+
Assert.False true
5363

5464
[<Fact>]
5565
member _.``File should be cached``(): unit =
@@ -84,6 +94,10 @@ type FileCacheTests(outputHelper: ITestOutputHelper) =
8494
|]
8595
}
8696

97+
[<Fact>]
98+
member _.``File cache cleanup works in order by file modification dates``(): unit =
99+
Assert.False true
100+
87101
[<Fact>]
88102
member _.``File should be read even after cleanup``(): unit =
89103
Assert.False true

Emulsion.sln.DotSettings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
<s:Boolean x:Key="/Default/UserDictionary/Words/=groupchat/@EntryIndexedValue">True</s:Boolean>
44
<s:Boolean x:Key="/Default/UserDictionary/Words/=Overquoted/@EntryIndexedValue">True</s:Boolean>
55
<s:Boolean x:Key="/Default/UserDictionary/Words/=Receival/@EntryIndexedValue">True</s:Boolean>
6+
<s:Boolean x:Key="/Default/UserDictionary/Words/=ttldtor/@EntryIndexedValue">True</s:Boolean>
67
<s:Boolean x:Key="/Default/UserDictionary/Words/=workflows/@EntryIndexedValue">True</s:Boolean>
78
<s:Boolean x:Key="/Default/UserDictionary/Words/=XMPP/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

0 commit comments

Comments
 (0)